Uebersicht: Knoten direkt bearbeiten; Editor als gemeinsamer Baustein (#24)
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:
adminandClaude Opus 5.5 committed 2026-10-16 07:21:17 +02:00
1 parent cd0504d9ca
commit bb08bb84a8
10 files changed
+386 -222

No files matched your search

+4
View File
@@ -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"