Uebersicht: Knoten direkt bearbeiten; Editor als gemeinsamer Baustein (#24)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
- ExpertEditorViewModel/ExpertEditorView aus dem Experte-Bereich ausgelagert (Feldhilfen, weitere Parameter, Befehlsvorschau, Sicherung vor der ersten Aenderung) - In der Uebersicht 'Bearbeiten' fuer Interfaces, IP-Adressen, DHCP-Server und Firewall-/NAT-Regeln (nur mit 'Professioneller Netzwerker'); Knoten tragen Menuepfad und Erkennungsmerkmale, nach dem Speichern wird die Uebersicht neu geladen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd0504d9ca
commit
bb08bb84a8
10 files changed
+386
-222
No files matched your search
@@ -6,6 +6,7 @@
|
||||
xmlns:steps="clr-namespace:MikrotikInstaller.App.ViewModels.Steps"
|
||||
xmlns:stepViews="clr-namespace:MikrotikInstaller.App.Views.Steps"
|
||||
xmlns:converters="clr-namespace:MikrotikInstaller.App.Converters"
|
||||
xmlns:viewControls="clr-namespace:MikrotikInstaller.App.Views.Controls"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
@@ -37,6 +38,9 @@
|
||||
<DataTemplate DataType="{x:Type steps:BackupManagementStepViewModel}">
|
||||
<stepViews:BackupManagementStepView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:ExpertEditorViewModel}">
|
||||
<viewControls:ExpertEditorView />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="{x:Type steps:ExpertStepViewModel}">
|
||||
<stepViews:ExpertStepView />
|
||||
</DataTemplate>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Expert;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Bearbeiten-Bereich für einen RouterOS-Eintrag — im Experte-Bereich und in der Übersicht (Issue #24)
|
||||
/// derselbe: Feldhilfen aus dem Katalog, „Weitere Parameter“, Befehlsvorschau, Speichern erst bei Änderung,
|
||||
/// Sicherung vor der ersten Änderung einer Sitzung.
|
||||
/// </summary>
|
||||
public partial class ExpertEditorViewModel : ObservableObject
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
private readonly MenuSchema? _schema;
|
||||
private readonly IReadOnlyDictionary<string, string>? _row;
|
||||
private readonly Func<Task> _onSaved;
|
||||
|
||||
public ExpertEditorViewModel(
|
||||
WizardSession session, string path, MenuSchema? schema, IReadOnlyDictionary<string, string>? row,
|
||||
IReadOnlyList<string> interfaceNames, Func<Task> onSaved)
|
||||
{
|
||||
_session = session;
|
||||
Path = path;
|
||||
_schema = schema;
|
||||
_row = row;
|
||||
_onSaved = onSaved;
|
||||
Title = row is null ? "Neuer Eintrag"
|
||||
: ExpertMenus.IsTrue(row, "dynamic") ? "Eintrag ansehen (dynamisch, von RouterOS verwaltet)"
|
||||
: "Eintrag bearbeiten";
|
||||
BuildFields(interfaceNames);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public ObservableCollection<ExpertFieldViewModel> Fields { get; } = [];
|
||||
|
||||
/// <summary>Zusätzliche Parameter als „name=wert“-Zeilen, z. B. für Felder ohne Katalog-Eintrag.</summary>
|
||||
[ObservableProperty]
|
||||
private string _extraParameters = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string? _previewText;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private bool _isSaving;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _errorMessage;
|
||||
|
||||
/// <summary>Wird ausgelöst, wenn der Editor geschlossen werden soll (nach dem Speichern oder per „Schließen“).</summary>
|
||||
public event EventHandler? Closed;
|
||||
|
||||
private void BuildFields(IReadOnlyList<string> interfaceNames)
|
||||
{
|
||||
foreach (var field in _schema?.Fields ?? [])
|
||||
{
|
||||
var options = field.Kind switch
|
||||
{
|
||||
"enum" => field.Options ?? [],
|
||||
"interfacePick" => interfaceNames,
|
||||
_ => [],
|
||||
};
|
||||
var item = new ExpertFieldViewModel(field.Key, field.Label, field.Help, field.Kind, options, _row?.GetValueOrDefault(field.Key) ?? string.Empty);
|
||||
if (_row is null && field.Default is { } defaultValue)
|
||||
{
|
||||
// Neuer Eintrag: Katalog-Standardwert vorschlagen (zählt als Änderung, wird also mitgeschickt).
|
||||
item.Value = defaultValue;
|
||||
}
|
||||
|
||||
Add(item);
|
||||
}
|
||||
|
||||
// Weitere Felder des Eintrags, die der Katalog nicht kennt (oder alle, bei einem eigenen Menüpfad).
|
||||
foreach (var (key, value) in _row ?? new Dictionary<string, string>())
|
||||
{
|
||||
if (key.StartsWith('.') || Fields.Any(f => f.Key == key) || key is "dynamic" or "invalid" or "running" or "default")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Add(new ExpertFieldViewModel(key, key, "Weiterer Parameter dieses Eintrags.", value is "true" or "false" ? "bool" : "text", [], value));
|
||||
}
|
||||
|
||||
UpdatePreview();
|
||||
}
|
||||
|
||||
private void Add(ExpertFieldViewModel field)
|
||||
{
|
||||
field.PropertyChanged += (_, _) => UpdatePreview();
|
||||
Fields.Add(field);
|
||||
}
|
||||
|
||||
partial void OnExtraParametersChanged(string value) => UpdatePreview();
|
||||
|
||||
private Dictionary<string, string> CollectChanges()
|
||||
{
|
||||
var original = _row is null ? null : Fields.ToDictionary(f => f.Key, f => f.Original);
|
||||
var edited = Fields.ToDictionary(f => f.Key, f => f.Value);
|
||||
foreach (var line in ExtraParameters.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator > 0)
|
||||
{
|
||||
edited[line[..separator].Trim()] = line[(separator + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return ExpertMenus.Changes(original, edited);
|
||||
}
|
||||
|
||||
private void UpdatePreview()
|
||||
{
|
||||
var changes = CollectChanges();
|
||||
PreviewText = changes.Count == 0
|
||||
? null
|
||||
: _schema is { Singleton: true }
|
||||
? ExpertMenus.Preview(Path, "set", null, changes)
|
||||
: ExpertMenus.Preview(Path, _row is null ? "add" : "set", _row.GetValueOrDefault(".id"), changes);
|
||||
}
|
||||
|
||||
private bool CanSave => PreviewText is not null && !IsSaving;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_session.Client is not { } client)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_row is not null && _schema is not { Singleton: true } && !_row.ContainsKey(".id"))
|
||||
{
|
||||
// Ohne ID würde aus „ändern“ ein neuer Eintrag.
|
||||
ErrorMessage = "Dieser Eintrag hat keine ID und kann hier nicht geändert werden.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
if (!_session.ExpertBackupDone)
|
||||
{
|
||||
await _session.CreateSafetyBackupAsync(client);
|
||||
_session.ExpertBackupDone = true;
|
||||
}
|
||||
|
||||
await ExpertMenus.SaveAsync(client, _schema, Path, _row?.GetValueOrDefault(".id"), CollectChanges());
|
||||
LastSavedCommand = PreviewText;
|
||||
Closed?.Invoke(this, EventArgs.Empty);
|
||||
await _onSaved();
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der zuletzt ausgeführte Befehl, für die Erfolgsmeldung des Aufrufers.</summary>
|
||||
public string? LastSavedCommand { get; private set; }
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => Closed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -88,6 +88,7 @@ public partial class ExpertFieldViewModel : ObservableObject
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <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
|
||||
@@ -96,8 +97,6 @@ public partial class ExpertFieldViewModel : ObservableObject
|
||||
public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
{
|
||||
private readonly WizardSession _session;
|
||||
private IReadOnlyDictionary<string, string>? _editingRow;
|
||||
private bool _safetyBackupDone;
|
||||
|
||||
public ExpertStepViewModel(WizardSession session)
|
||||
: base(
|
||||
@@ -118,8 +117,6 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
|
||||
public ObservableCollection<ExpertEntryViewModel> Entries { get; } = [];
|
||||
|
||||
public ObservableCollection<ExpertFieldViewModel> Fields { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsMenuSelected))]
|
||||
[NotifyPropertyChangedFor(nameof(IsToolSelected))]
|
||||
@@ -144,19 +141,9 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
[ObservableProperty]
|
||||
private string? _statusMessage;
|
||||
|
||||
/// <summary>Der geöffnete Bearbeiten-Bereich, null wenn geschlossen (gemeinsam mit der Übersicht, Issue #24).</summary>
|
||||
[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;
|
||||
private ExpertEditorViewModel? _editor;
|
||||
|
||||
public override async Task OnActivatedAsync()
|
||||
{
|
||||
@@ -170,7 +157,7 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
partial void OnSelectedMenuChanged(ExpertMenuItem? value)
|
||||
{
|
||||
Tools.ActiveTool = value?.Tool;
|
||||
CloseEditor();
|
||||
Editor = null;
|
||||
Entries.Clear();
|
||||
ErrorMessage = null;
|
||||
StatusMessage = null;
|
||||
@@ -237,151 +224,32 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
: 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);
|
||||
}
|
||||
private void EditEntry(ExpertEntryViewModel entry) => OpenEditor(entry.Row);
|
||||
|
||||
[RelayCommand]
|
||||
private void NewEntry()
|
||||
private void NewEntry() => OpenEditor(null);
|
||||
|
||||
private void OpenEditor(IReadOnlyDictionary<string, string>? row)
|
||||
{
|
||||
_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)
|
||||
if (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)
|
||||
ExpertEditorViewModel? editor = null;
|
||||
editor = new ExpertEditorViewModel(_session, path, SelectedMenu.Schema, row, Tools.InterfaceNames, async () =>
|
||||
{
|
||||
StatusMessage = $"Gespeichert: {editor!.LastSavedCommand}";
|
||||
await LoadEntriesAsync();
|
||||
}
|
||||
});
|
||||
editor.Closed += (_, _) =>
|
||||
{
|
||||
if (Editor == editor)
|
||||
{
|
||||
Editor = null;
|
||||
}
|
||||
};
|
||||
Editor = editor;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -403,7 +271,13 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await EnsureSafetyBackupAsync(client);
|
||||
if (!_session.ExpertBackupDone)
|
||||
{
|
||||
StatusMessage = "Sicherung vor der ersten Änderung wird erstellt …";
|
||||
await _session.CreateSafetyBackupAsync(client);
|
||||
_session.ExpertBackupDone = true;
|
||||
}
|
||||
|
||||
await client.RemoveAsync(path, id);
|
||||
StatusMessage = $"Gelöscht: {preview}";
|
||||
}
|
||||
@@ -418,17 +292,4 @@ public partial class ExpertStepViewModel : WizardStepViewModelBase
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using MikrotikInstaller.Core.Analysis;
|
||||
using MikrotikInstaller.Core.Connectivity;
|
||||
using MikrotikInstaller.Core.Devices;
|
||||
using MikrotikInstaller.Core.Expert;
|
||||
|
||||
namespace MikrotikInstaller.App.ViewModels.Steps;
|
||||
|
||||
@@ -96,11 +97,67 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
}
|
||||
|
||||
/// <summary>Wird ausgelöst, wenn der Nutzer in der Grafik einen anderen Knoten anklickt (<c>NodifyEditor.SelectedItem</c>).</summary>
|
||||
partial void OnSelectedTopologyItemChanged(object? value) => HighlightConnectedTo(value as TopologyNodeViewModel);
|
||||
partial void OnSelectedTopologyItemChanged(object? value)
|
||||
{
|
||||
HighlightConnectedTo(value as TopologyNodeViewModel);
|
||||
OnPropertyChanged(nameof(CanEditSelected));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearSelection() => SelectedTopologyItem = null;
|
||||
|
||||
/// <summary>Bearbeiten im Diagramm (Issue #24, Mac „Übersicht“): nur mit „Professioneller Netzwerker“.</summary>
|
||||
public bool CanEditSelected => UserSettings.Current.ProfessionalMode && SelectedTopologyItem is TopologyNodeViewModel { EditPath: not null };
|
||||
|
||||
/// <summary>Derselbe Bearbeiten-Bereich wie im Experte-Bereich, null wenn geschlossen.</summary>
|
||||
[ObservableProperty]
|
||||
private ExpertEditorViewModel? _editor;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _editMessage;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditSelectedNodeAsync()
|
||||
{
|
||||
if (_session.Client is not { } client || SelectedTopologyItem is not TopologyNodeViewModel { EditPath: { } path, EditMatch: { } match })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EditMessage = null;
|
||||
try
|
||||
{
|
||||
var row = (await client.GetAsync(path)).FirstOrDefault(r => match.All(kv => kv.Key == "@bound"
|
||||
? new[] { "to-addresses", "dst-address", "src-address" }.Any(k => r.GetValueOrDefault(k)?.Split('/')[0] == kv.Value.Split('/')[0])
|
||||
: r.GetValueOrDefault(kv.Key) == kv.Value));
|
||||
if (row is null)
|
||||
{
|
||||
EditMessage = "Den Eintrag gibt es auf dem Router so nicht mehr — bitte die Übersicht neu laden.";
|
||||
return;
|
||||
}
|
||||
|
||||
var interfaces = (await client.GetAsync("/interface")).Select(i => i.GetValueOrDefault("name")).OfType<string>().ToList();
|
||||
ExpertEditorViewModel? editor = null;
|
||||
editor = new ExpertEditorViewModel(_session, path, MenuCatalog.Find(path), row, interfaces, async () =>
|
||||
{
|
||||
EditMessage = $"Gespeichert: {editor!.LastSavedCommand}";
|
||||
await OnActivatedAsync();
|
||||
});
|
||||
editor.Closed += (_, _) =>
|
||||
{
|
||||
if (Editor == editor)
|
||||
{
|
||||
Editor = null;
|
||||
}
|
||||
};
|
||||
Editor = editor;
|
||||
}
|
||||
catch (RouterOsException ex)
|
||||
{
|
||||
EditMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hebt den gesamten mit dem angeklickten Knoten verbundenen Teilbaum hervor: die Eltern-Kette
|
||||
/// bis zur Wurzel (der Weg zum Internet-Zugang, die "Hops") UND alle Nachfahren (alles, was von
|
||||
@@ -241,7 +298,18 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
? $"{interfaceName} ({ssid})"
|
||||
: interfaceName;
|
||||
|
||||
nodesById[id] = new TopologyNodeViewModel(id, label, kind, parentId);
|
||||
nodesById[id] = new TopologyNodeViewModel(id, label, kind, parentId)
|
||||
{
|
||||
EditPath = kind switch
|
||||
{
|
||||
TopologyNodeKind.Ethernet => "/interface/ethernet",
|
||||
TopologyNodeKind.Bridge => "/interface/bridge",
|
||||
TopologyNodeKind.Vlan => "/interface/vlan",
|
||||
TopologyNodeKind.Wireless => "/interface/wireless",
|
||||
_ => "/interface",
|
||||
},
|
||||
EditMatch = new Dictionary<string, string> { ["name"] = interfaceName },
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var address in snapshot.IpAddresses)
|
||||
@@ -252,7 +320,11 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
}
|
||||
|
||||
var id = $"ip:{address.InterfaceName}:{address.AddressCidr}";
|
||||
nodesById[id] = new TopologyNodeViewModel(id, address.AddressCidr, TopologyNodeKind.IpAddress, InterfaceNodeId(address.InterfaceName));
|
||||
nodesById[id] = new TopologyNodeViewModel(id, address.AddressCidr, TopologyNodeKind.IpAddress, InterfaceNodeId(address.InterfaceName))
|
||||
{
|
||||
EditPath = "/ip/address",
|
||||
EditMatch = new Dictionary<string, string> { ["address"] = address.AddressCidr, ["interface"] = address.InterfaceName },
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var dhcp in snapshot.DhcpServers)
|
||||
@@ -263,7 +335,11 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
}
|
||||
|
||||
var id = $"dhcp:{dhcp.Name}";
|
||||
nodesById[id] = new TopologyNodeViewModel(id, dhcp.Name, TopologyNodeKind.DhcpServer, InterfaceNodeId(dhcp.InterfaceName), dhcp.AddressRange);
|
||||
nodesById[id] = new TopologyNodeViewModel(id, dhcp.Name, TopologyNodeKind.DhcpServer, InterfaceNodeId(dhcp.InterfaceName), dhcp.AddressRange)
|
||||
{
|
||||
EditPath = "/ip/dhcp-server",
|
||||
EditMatch = new Dictionary<string, string> { ["name"] = dhcp.Name },
|
||||
};
|
||||
}
|
||||
|
||||
// Firewall-/NAT-Regeln unter der IP-Adresse einhängen, deren Subnetz die gebundene Adresse
|
||||
@@ -287,7 +363,11 @@ public partial class NetworkAnalysisStepViewModel : WizardStepViewModelBase
|
||||
var detail = rule.Table == "nat" ? "Portfreigabe" : rule.BoundAddress;
|
||||
|
||||
var id = $"fw:{firewallIndex}";
|
||||
nodesById[id] = new TopologyNodeViewModel(id, label, TopologyNodeKind.Firewall, parentIpNode?.Id, detail);
|
||||
nodesById[id] = new TopologyNodeViewModel(id, label, TopologyNodeKind.Firewall, parentIpNode?.Id, detail)
|
||||
{
|
||||
EditPath = $"/ip/firewall/{rule.Table}",
|
||||
EditMatch = new Dictionary<string, string> { ["chain"] = rule.Chain, ["action"] = rule.Action, ["@bound"] = rule.BoundAddress },
|
||||
};
|
||||
}
|
||||
|
||||
AssignLayout(nodesById.Values, DiagramItems);
|
||||
|
||||
@@ -18,6 +18,12 @@ public partial class TopologyNodeViewModel(string id, string label, TopologyNode
|
||||
/// <summary>Zusätzliche, laientaugliche Zusatzinfo für die Grafik (z. B. DHCP-Adressbereich, Gateway-IP).</summary>
|
||||
public string? Detail { get; } = detail;
|
||||
|
||||
/// <summary>RouterOS-Menüpfad des Eintrags hinter diesem Knoten, null wenn nicht bearbeitbar (Issue #24).</summary>
|
||||
public string? EditPath { get; init; }
|
||||
|
||||
/// <summary>Felder, an denen der Eintrag wiedergefunden wird; "@bound" = eine der Adressfelder einer Firewall-Regel.</summary>
|
||||
public IReadOnlyDictionary<string, string>? EditMatch { get; init; }
|
||||
|
||||
[ObservableProperty]
|
||||
private Point _location;
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ public sealed class WizardSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Vor der ersten Änderung im Experte-Bereich/in der Übersicht wurde schon gesichert.</summary>
|
||||
public bool ExpertBackupDone { get; set; }
|
||||
|
||||
/// <summary>Zuletzt von Hand erstelltes Backup (Name, Zeitpunkt).</summary>
|
||||
public (string Name, DateTimeOffset At)? LastManualBackup { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<UserControl x:Class="MikrotikInstaller.App.Views.Controls.ExpertEditorView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls">
|
||||
<!-- DataContext: ExpertEditorViewModel. -->
|
||||
<Border Background="{DynamicResource ControlFillColorSecondaryBrush}" CornerRadius="8" Padding="14" Margin="0,0,0,12">
|
||||
<StackPanel>
|
||||
<TextBlock FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10">
|
||||
<Run Text="{Binding Title, Mode=OneWay}" />
|
||||
<Run Text="{Binding Path, Mode=OneWay}" FontSize="12" FontFamily="Consolas" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</TextBlock>
|
||||
<ItemsControl ItemsSource="{Binding Fields}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" MaxWidth="170" />
|
||||
<controls:InfoHint Text="{Binding Help}" />
|
||||
</StackPanel>
|
||||
<ui:TextBox Grid.Column="1" Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||
Visibility="{Binding IsText, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<ComboBox Grid.Column="1" IsEditable="True" ItemsSource="{Binding Options}" Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||
Visibility="{Binding HasOptions, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<CheckBox Grid.Column="1" IsChecked="{Binding IsChecked}"
|
||||
Visibility="{Binding IsBool, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="6" Padding="10" Margin="0,4,0,10">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
|
||||
<TextBlock Text="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}" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,0,0,10"
|
||||
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ui:Button Content="Speichern" Appearance="Primary" Margin="0,0,8,0" Command="{Binding SaveCommand}" />
|
||||
<ui:Button Content="Schließen" Command="{Binding CloseCommand}" />
|
||||
<ui:ProgressRing IsIndeterminate="True" Width="18" Height="18" Margin="12,0,0,0"
|
||||
Visibility="{Binding IsSaving, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MikrotikInstaller.App.Views.Controls;
|
||||
|
||||
public partial class ExpertEditorView : System.Windows.Controls.UserControl
|
||||
{
|
||||
public ExpertEditorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -144,55 +144,8 @@
|
||||
</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>
|
||||
<!-- Editor (gemeinsam mit der Übersicht, Issue #24) -->
|
||||
<ContentControl Content="{Binding Editor}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Werkzeuge -->
|
||||
|
||||
@@ -75,9 +75,20 @@
|
||||
|
||||
<TextBlock Grid.Row="0" Text="{Binding Description}" TextWrapping="Wrap" Margin="0,0,0,16" Opacity="0.8" />
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<StackPanel Grid.Row="1" Margin="0,0,0,12">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ui:Button Content="Pfad zurücksetzen" Appearance="Secondary" Command="{Binding ClearSelectionCommand}"
|
||||
Visibility="{Binding SelectedTopologyItem, Converter={StaticResource NullToVisibilityConverter}}" />
|
||||
<ui:Button Content="Bearbeiten" Appearance="Secondary" Margin="8,0,0,0" Command="{Binding EditSelectedNodeCommand}"
|
||||
Visibility="{Binding CanEditSelected, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="Edit24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding EditMessage}" TextWrapping="Wrap" Margin="0,8,0,0" FontFamily="Consolas"
|
||||
Visibility="{Binding EditMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||
<ContentControl Content="{Binding Editor}" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" VerticalAlignment="Top"
|
||||
|
||||
Reference in new issue
Block a user