From ff94be87b20c1377ece4b796d9d92a83f20d5b26 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sat, 7 Mar 2026 21:11:12 +1000 Subject: [PATCH] Effective config: multi descriptor refactor, validation UX, restore cleanup - All multi fields use EffectiveMultiValueConfigFieldDescriptor; registry has RegisterMultiDescriptor/RegisterMultiComponent/RegisterMulti; every multi option registered; plain multi fallback removed (AddMultiDescriptor throws). - Descriptor-driven validation: base descriptor ValidateValue + Validate(); integer descriptor enforces Min/Max; ListenAddressMultiValidator for listen-address; single/flag commit uses Descriptor.Validate (no session field issues for inline edits). - Multi list editor: local _displayItems so remove/add/edit update UI immediately; remove button PreventBlurOnMouseDown; inline validation message under input (ec-multi-list-input-group); root div not span. - Save flow: on successful backup restore call OnCloseAndEndEdit then OnSaveCompleted so session and pending changes are cleared and config is reloaded. - Server uses descriptor-only (no custom multi component); ServerMultiValueDisplay removed. RegisterValidatedSingle added for future single-field validation. --- .../EffectiveDnsmasqConfigTests.cs | 2 +- .../Composition/EffectiveConfigSection.razor | 14 +- .../Composition/EffectiveConfigToolbar.razor | 16 ++ .../CustomDisplays/IntegerValueDisplay.razor | 15 +- .../Fields/EffectiveConfigFieldDisplay.razor | 51 ++--- .../EffectiveConfigMultiValueListEditor.razor | 175 +++++++++++++----- ...ectiveConfigMultiValueListEditor.razor.css | 36 +++- .../Fields/EffectiveConfigMultiValueRow.razor | 10 +- .../Modals/EffectiveConfigSaveFlow.razor | 7 +- ...tiveConfigFieldDescriptorListExtensions.cs | 9 +- .../Services/Common/Process/ProcessHandle.cs | 55 +++--- .../IEffectiveConfigEditSession.cs | 16 +- .../IEffectiveConfigRenderFragmentRegistry.cs | 10 + .../EffectiveConfigEditSession.cs | 56 ++++++ .../EffectiveConfigRenderFragmentRegistry.cs | 135 +++++++++++++- .../DefaultMultiValueEditBehavior.cs | 11 ++ .../EffectiveConfigCrossOptionEvaluator.cs | 58 ++++++ .../Validation/IMultiValueEditBehavior.cs | 16 ++ .../Validation/IMultiValueOptionValidator.cs | 15 ++ .../Validation/ListenAddressMultiValidator.cs | 23 +++ .../Validation/ServerMultiBehavior.cs | 12 ++ .../Validation/ServerMultiValidator.cs | 33 ++++ .../EffectiveConfigDescriptorFactory.cs | 10 + .../IEffectiveConfigFieldDescriptor.cs | 2 + .../EffectiveConfigFieldDescriptor.cs | 7 +- .../EffectiveIntegerConfigFieldDescriptor.cs | 19 +- ...ffectiveMultiValueConfigFieldDescriptor.cs | 24 +++ .../Dnsmasq/EffectiveConfig/FieldIssue.cs | 19 ++ 28 files changed, 750 insertions(+), 106 deletions(-) create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueOptionValidator.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveMultiValueConfigFieldDescriptor.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/FieldIssue.cs diff --git a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs index 86838d8..315d067 100644 --- a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs @@ -120,7 +120,7 @@ public class EffectiveDnsmasqConfigTests FastDnsRetry: null, DhcpScriptPath: null, MxTarget: null, - Conntrack: null + Conntrack: false ); [Fact] diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index ab46771..2bb4c63 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -3,6 +3,7 @@ @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation @inject IEffectiveConfigEditSession Session @inject IJSRuntime JSRuntime @@ -17,6 +18,7 @@ } @@ -40,6 +42,9 @@ private HashSet _openPanels = new(); private bool _showSaveModal; + private int _validationErrorCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Error); + private int _validationWarningCount => Session.GetValidationSummary().Count(i => i.Severity == FieldIssueSeverity.Warning); + private string? _optionHelpKey; private string? _optionHelpAnchorId; private string _optionHelpTitle = "Option help"; @@ -69,7 +74,7 @@ ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; }, DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); }, CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }, - RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); return InvokeAsync(StateHasChanged); } + RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); } }; } @@ -184,9 +189,16 @@ private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args) { Session.TrackCommit(args); + RunCrossOptionEvaluator(); StateHasChanged(); } + private void RunCrossOptionEvaluator() + { + var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges); + Session.SetCrossOptionIssues(issues); + } + private void OpenSaveModal() { if (Session.PendingChanges.Count == 0) return; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor index 41934fb..7614d7f 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigToolbar.razor @@ -30,6 +30,20 @@ @PendingChangesCount change(s) } + @if (ValidationErrorCount > 0 || ValidationWarningCount > 0) + { + + @if (ValidationErrorCount > 0) + { + errors: @ValidationErrorCount + } + @if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { · } + @if (ValidationWarningCount > 0) + { + warnings: @ValidationWarningCount + } + + } } else { @@ -46,6 +60,8 @@ [Parameter] public EventCallback SearchTermChanged { get; set; } [Parameter] public bool IsEditMode { get; set; } [Parameter] public int PendingChangesCount { get; set; } + [Parameter] public int ValidationErrorCount { get; set; } + [Parameter] public int ValidationWarningCount { get; set; } [Parameter] public bool AllSectionsExpanded { get; set; } [Parameter] public EventCallback OnExpandAll { get; set; } [Parameter] public EventCallback OnCollapseAll { get; set; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor index fce9338..b93cb83 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/IntegerValueDisplay.razor @@ -34,9 +34,20 @@ else private async Task OnInput(ChangeEventArgs e) { - int? value = null; - if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max) + var raw = e.Value?.ToString(); + object? value = null; + if (string.IsNullOrWhiteSpace(raw)) + { + value = null; + } + else if (int.TryParse(raw, out var num)) + { value = num; + } + else + { + value = raw; + } await NotifyValueChanged(value); } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index b234e50..d55180e 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -5,8 +5,6 @@ @using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions -@using System.Net -@using System.Net.Sockets @inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry @inject INotificationService NotificationService @@ -44,11 +42,32 @@ } else if (Descriptor.IsMultiValue) { - @* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@ - + var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create>(this, HandleMultiValueChanged)); + if (customMulti is not null) + { + @* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@ +
  • + @Descriptor.OptionName: + + + + @customMulti(Descriptor) + + + + +
  • + } + else + { + @* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@ + + } } else { @@ -191,7 +210,7 @@ else await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName); return true; } - var validationError = ValidateDraftValue(newValue); + var validationError = Descriptor.Validate(newValue); if (validationError != null) { NotificationService.ShowError(validationError); @@ -261,22 +280,6 @@ else await Ui.DeactivateFieldAsync(); } - private string? ValidateDraftValue(object? value) - { - if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal)) - { - var values = AsStringList(value) ?? (value is string s ? new[] { s } : null); - if (values == null) return null; - foreach (var raw in values) - { - var v = raw.Trim(); - if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6)) - return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address."; - } - } - return null; - } - private static IReadOnlyList? AsStringList(object? value) { return value as IReadOnlyList; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor index 46f53a0..56b78a0 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor @@ -2,33 +2,44 @@ @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using Microsoft.AspNetCore.Components.Web +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation @inject IJSRuntime JSRuntime - +
      - @if (SourceItems?.Count > 0) + @if (_displayItems.Count > 0) { - @for (var i = 0; i < SourceItems.Count; i++) + @for (var i = 0; i < _displayItems.Count; i++) { - var item = SourceItems[i]; + var item = _displayItems[i]; var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}"; var itemReadonly = item.Source?.IsReadOnly == true; var rowIndex = i; var rowValue = item.Value; + var rowInlineError = GetInlineErrorForIndex(rowIndex); -
    1. +
    2. @if (_editingIndex == rowIndex && !itemReadonly) { - - - - - +
      +
      + + @if (rowInlineError != null) + { +
      @rowInlineError
      + } +
      + + + + +
      } else { @@ -48,6 +59,7 @@ { } @@ -64,20 +76,28 @@ @if (AllowAddManagedValue) { -
    3. +
    4. @if (_addingNew) { - - - - - +
      +
      + + @if (AddInlineError != null) + { +
      @AddInlineError
      + } +
      + + + + +
      } else { @@ -88,7 +108,7 @@
    5. }
    - +
    @code { [Parameter] public IReadOnlyList? SourceItems { get; set; } @@ -98,6 +118,10 @@ [Parameter] public bool IsEditMode { get; set; } [Parameter] public bool AllowAddManagedValue { get; set; } = true; + [Parameter] public IMultiValueEditBehavior? Behavior { get; set; } + [Parameter] public IMultiValueOptionValidator? Validator { get; set; } + [Parameter] public string? FieldKey { get; set; } + [Parameter] public EventCallback> OnValuesChanged { get; set; } private const string AddItemKey = "add-item"; @@ -107,9 +131,22 @@ private bool _didAutoOpenAdd; private bool _shouldFocusAddInput; private ElementReference _addNewInputRef; + private int? _inlineErrorIndex; + private string? _inlineErrorMessage; + + private List _displayItems = new(); + private IReadOnlyList _currentValues => _displayItems.Select(x => x.Value).ToList(); + + private string? AddInlineError => _addingNew && _inlineErrorIndex is null ? _inlineErrorMessage : null; + private string? GetInlineErrorForIndex(int? editIndex) + { + return _editingIndex == editIndex && _inlineErrorIndex == editIndex ? _inlineErrorMessage : null; + } protected override void OnParametersSet() { + _displayItems = (SourceItems ?? Array.Empty()).ToList(); + if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue) { _didAutoOpenAdd = true; @@ -139,6 +176,7 @@ _addingNew = false; _editingIndex = index; _draftValue = current; + ClearInlineError(); } private void BeginAdd() @@ -147,6 +185,7 @@ _addingNew = true; _draftValue = ""; _shouldFocusAddInput = true; + ClearInlineError(); } private void CancelInline() @@ -154,46 +193,96 @@ _editingIndex = null; _addingNew = false; _draftValue = ""; + ClearInlineError(); } private async Task SaveEdit(int index) { - var next = (SourceItems ?? Array.Empty()) - .Select(x => x.Value) - .ToList(); + var current = _currentValues; + var next = current.ToList(); if (index < 0 || index >= next.Count) return; - next[index] = (_draftValue ?? "").Trim(); + var raw = _draftValue ?? ""; + var normalized = Behavior?.Normalize(raw) ?? raw.Trim(); + if (normalized.Length == 0) + { + SetInlineError(index, "Value cannot be empty."); + return; + } + + var validationError = Validator?.ValidateItem(normalized, current, index); + if (validationError != null) + { + SetInlineError(index, validationError); + return; + } + + if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal))) + { + SetInlineError(index, "Duplicate values are not allowed."); + return; + } + + ClearInlineError(); + next[index] = normalized; + _displayItems[index] = _displayItems[index] with { Value = normalized }; await OnValuesChanged.InvokeAsync(next); CancelInline(); } private async Task SaveNew() { - var value = (_draftValue ?? "").Trim(); - if (value.Length == 0) return; + var raw = _draftValue ?? ""; + var normalized = Behavior?.Normalize(raw) ?? raw.Trim(); + if (normalized.Length == 0) return; - var next = (SourceItems ?? Array.Empty()) - .Select(x => x.Value) - .ToList(); + var current = _currentValues; + var validationError = Validator?.ValidateItem(normalized, current, null); + if (validationError != null) + { + SetInlineError(null, validationError); + return; + } - next.Add(value); + if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal)) + { + SetInlineError(null, "Duplicate values are not allowed."); + return; + } + + ClearInlineError(); + var next = current.ToList(); + next.Add(normalized); + _displayItems.Add(new ValueWithSource(normalized, null)); await OnValuesChanged.InvokeAsync(next); CancelInline(); } + private void SetInlineError(int? itemIndex, string message) + { + _inlineErrorIndex = itemIndex; + _inlineErrorMessage = message; + StateHasChanged(); + } + + private void ClearInlineError() + { + _inlineErrorIndex = null; + _inlineErrorMessage = null; + } + private async Task RemoveAt(int index) { - if (SourceItems == null || index < 0 || index >= SourceItems.Count) return; - var item = SourceItems[index]; + if (index < 0 || index >= _displayItems.Count) return; + var item = _displayItems[index]; if (item?.Source?.IsReadOnly == true) return; - var next = (SourceItems ?? Array.Empty()) - .Select(x => x.Value) - .ToList(); + var next = _displayItems.Select(x => x.Value).ToList(); if (index < 0 || index >= next.Count) return; next.RemoveAt(index); + _displayItems.RemoveAt(index); await OnValuesChanged.InvokeAsync(next); + StateHasChanged(); } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css index 16c6a92..6e4c8f2 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor.css @@ -20,6 +20,33 @@ min-height: 2rem; } +.ec-multi-list-row.ec-multi-list-row-with-controls { + align-items: flex-start; +} + +.ec-multi-list-row-controls { + display: flex; + align-items: flex-start; + gap: 0.5rem; + flex-wrap: nowrap; + min-width: 0; + width: 100%; +} + +.ec-multi-list-input-group { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + max-width: 28rem; +} + +.ec-multi-list-invalid-feedback { + margin-top: 0.25rem; + margin-bottom: 0; + width: 100%; +} + .ec-multi-list-row .ec-field-value-item { min-width: 0; } @@ -32,8 +59,9 @@ } .ec-multi-list-row .form-control.ec-multi-list-input { - max-width: 28rem; + max-width: none; flex-shrink: 0; + width: 100%; } .ec-multi-list-empty-hint { @@ -51,11 +79,13 @@ } .ec-multi-list-add-item .form-control.ec-multi-list-input { - flex: 1 1 auto; - max-width: none; min-width: 12rem; } +.ec-multi-list-add-item .ec-multi-list-input-group { + max-width: none; +} + .ec-multi-list-add-item .status-badge-inline { margin-left: auto; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor index 9f47ddf..bdbb251 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor @@ -12,6 +12,9 @@ ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")" IsEditMode="@IsEditMode" AllowAddManagedValue="@IsEditMode" + FieldKey="@FieldKey" + Behavior="@(_multiDesc?.Behavior)" + Validator="@(_multiDesc?.Validator)" OnValuesChanged="@ItemsChanged" /> +
    @if (Items?.Count > 0) {
      @@ -37,12 +40,13 @@ } - +
    } @code { [Parameter] public string Label { get; set; } = ""; + [Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; } [Parameter] public IReadOnlyList? Items { get; set; } [Parameter] public EventCallback> ItemsChanged { get; set; } [Parameter] public string? LabelTooltip { get; set; } @@ -58,4 +62,6 @@ [Parameter] public EventCallback OnConfirmRequested { get; set; } [Parameter] public EventCallback OnCancelRequested { get; set; } [Parameter] public EventCallback OnRevertRequested { get; set; } + + private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index c425dae..90442b9 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -64,6 +64,11 @@ private async Task HandleSave() { if (Session.PendingChanges.Count == 0) return; + if (Session.HasBlockingValidationErrors()) + { + NotificationService.ShowError("Fix validation errors before saving."); + return; + } _saveState = SaveUiState.Saving; _saveResult = null; @@ -114,7 +119,7 @@ _saveState = SaveUiState.Review; _saveResult = null; _restoreResult = null; - await OnClose.InvokeAsync(); + await OnCloseAndEndEdit.InvokeAsync(); await OnSaveCompleted.InvokeAsync(); return; } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldDescriptorListExtensions.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldDescriptorListExtensions.cs index e0249d4..c48ec11 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldDescriptorListExtensions.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldDescriptorListExtensions.cs @@ -22,7 +22,7 @@ public static class EffectiveConfigFieldDescriptorListExtensions Func?>? getItems) { if (EffectiveConfigOptionKindMap.GetKind(optionName) == EffectiveConfigFieldKind.Multi) - list.AddMultiDescriptor(optionName, status, getItems); + list.AddMultiDescriptor(registry, optionName, status, getItems); else list.AddSingleDescriptor(registry, optionName, status, getValue, getSource, getItems); } @@ -43,15 +43,18 @@ public static class EffectiveConfigFieldDescriptorListExtensions } /// - /// Adds a multi-value field descriptor (e.g. server, address, dhcp-range). + /// Adds a multi-value field descriptor. Every multi option must be registered in the registry; uses the registry's multi descriptor factory. /// public static void AddMultiDescriptor( this List list, + IEffectiveConfigRenderFragmentRegistry? registry, string optionName, DnsmasqServiceStatus? status, Func?>? getItems) { var sectionId = EffectiveConfigSections.GetSectionId(optionName); - list.Add(new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems)); + var factory = registry?.GetMultiDescriptorFactory(sectionId, optionName) + ?? throw new InvalidOperationException($"No multi descriptor factory registered for option '{optionName}' (section '{sectionId}'). Register all multi options via RegisterMultiDescriptor."); + list.Add(factory(sectionId, optionName, status, getItems)); } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs index eb7845d..de8a1b0 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs @@ -32,17 +32,8 @@ internal sealed class ProcessHandle : IProcessHandle _logger = logger; process.EnableRaisingEvents = true; - process.Exited += (_, _) => - { - lock (_gate) - { - if (!_channelCompleted) - { - _channelCompleted = true; - _channel.Writer.Complete(); - } - } - }; + // Do not complete the channel in Exited: OutputDataReceived/ErrorDataReceived can fire after Exited + // (see dotnet/runtime#18789). Complete only when both streams have closed (e.Data == null). process.OutputDataReceived += (_, e) => { @@ -52,7 +43,10 @@ internal sealed class ProcessHandle : IProcessHandle _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow)); } else + { _stdoutClosed.TrySetResult(); + TryCompleteChannelWhenBothStreamsClosed(); + } }; process.ErrorDataReceived += (_, e) => @@ -63,10 +57,35 @@ internal sealed class ProcessHandle : IProcessHandle _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow)); } else + { _stderrClosed.TrySetResult(); + TryCompleteChannelWhenBothStreamsClosed(); + } }; } + private void TryCompleteChannelWhenBothStreamsClosed() + { + lock (_gate) + { + if (_channelCompleted) return; + if (!_stdoutClosed.Task.IsCompleted || !_stderrClosed.Task.IsCompleted) return; + _channelCompleted = true; + _channel.Writer.Complete(); + } + } + + /// Complete the channel after waiting for streams; used when stream-close wait timed out or caller is shutting down. + private void CompleteChannelIfNeeded() + { + lock (_gate) + { + if (_channelCompleted) return; + _channelCompleted = true; + _channel.Writer.Complete(); + } + } + public async IAsyncEnumerable ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default) { await foreach (var line in _channel.Reader.ReadAllAsync(ct)) @@ -100,24 +119,18 @@ internal sealed class ProcessHandle : IProcessHandle var timeoutSeconds = timeout?.TotalSeconds ?? 0; _logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds); try { _process.Kill(); } catch { /* best effort */ } - lock (_gate) - { - if (!_channelCompleted) - { - _channelCompleted = true; - _channel.Writer.Complete(); - } - } - try { await drainTask; } catch (OperationCanceledException) { } await WaitForStreamsClosedAsync(); + CompleteChannelIfNeeded(); + try { await drainTask; } catch (OperationCanceledException) { } var err = _stderr.ToString(); if (!string.IsNullOrEmpty(err)) err += "\n"; err += $"Command timed out after {timeoutSeconds} seconds."; return new ProcessRunResult(null, _stdout.ToString(), err, true); } - try { await drainTask; } catch (OperationCanceledException) { } await WaitForStreamsClosedAsync(); + CompleteChannelIfNeeded(); + try { await drainTask; } catch (OperationCanceledException) { } var exitCode = _process.HasExited ? _process.ExitCode : -1; _logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs index 4c2f13f..b41f13f 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs @@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; /// -/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, and apply. +/// Scoped orchestrator for effective-config edit lifecycle: edit mode, active field, pending changes, validation, and apply. /// Components read state from here and call methods; the owning section re-renders after state changes. /// public interface IEffectiveConfigEditSession : IApplicationScopedService @@ -13,6 +13,9 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService string? ActiveFieldKey { get; } IReadOnlyList PendingChanges { get; } + /// Per-field validation issues (errors block save; warnings can be confirmed). + IReadOnlyDictionary> FieldIssues { get; } + void EnterEditMode(); void ExitEditModeDiscard(); void ActivateField(string fieldKey); @@ -21,5 +24,16 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService void TrackCommit(EffectiveConfigEditCommittedArgs args); void RevertChange(string sectionId, string optionName); + void SetFieldIssues(string fieldKey, IReadOnlyList issues); + void ClearFieldIssues(string fieldKey); + + /// Replaces all cross-option validation issues (e.g. no-resolv vs server). Merged with per-field issues for display and save guard. + void SetCrossOptionIssues(IReadOnlyList issues); + + /// True if any field has one or more issues with . + bool HasBlockingValidationErrors(); + /// All issues across fields for summary display (e.g. toolbar count or save guard message). + IReadOnlyList GetValidationSummary(); + Task ApplyAsync(CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigRenderFragmentRegistry.cs index f9f4b44..d74e749 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigRenderFragmentRegistry.cs @@ -27,4 +27,14 @@ public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton /// Returns a factory that creates the correct descriptor type for this field (e.g. ), or null to use the default. /// EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName); + + /// + /// Builds a fragment that renders the custom multi-value component for this field, or null to use the generic list editor. + /// + RenderFragment? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback> onValuesChanged); + + /// + /// Returns a factory that creates a multi-value descriptor (e.g. ), or null to use the plain descriptor. + /// + EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index 5e406c5..dd694a2 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -7,6 +7,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession { private readonly IEffectiveConfigSaveService _saveService; private readonly List _pending = new(); + private readonly Dictionary> _fieldIssues = new(StringComparer.OrdinalIgnoreCase); + private readonly List _crossOptionIssues = new(); public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService) { @@ -16,17 +18,37 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession public bool IsEditMode { get; private set; } public string? ActiveFieldKey { get; private set; } public IReadOnlyList PendingChanges => _pending; + public IReadOnlyDictionary> FieldIssues + { + get + { + var copy = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fieldIssues) + copy[kv.Key] = new List(kv.Value); + foreach (var issue in _crossOptionIssues) + { + if (!copy.TryGetValue(issue.FieldKey, out var list)) + copy[issue.FieldKey] = list = new List(); + list.Add(issue); + } + return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); + } + } public void EnterEditMode() { IsEditMode = true; ActiveFieldKey = null; _pending.Clear(); + _fieldIssues.Clear(); + _crossOptionIssues.Clear(); } public void ExitEditModeDiscard() { _pending.Clear(); + _fieldIssues.Clear(); + _crossOptionIssues.Clear(); ActiveFieldKey = null; IsEditMode = false; } @@ -42,6 +64,39 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession ActiveFieldKey = null; } + public void SetFieldIssues(string fieldKey, IReadOnlyList issues) + { + if (string.IsNullOrEmpty(fieldKey)) return; + _fieldIssues[fieldKey] = issues.ToList(); + } + + public void ClearFieldIssues(string fieldKey) + { + if (string.IsNullOrEmpty(fieldKey)) return; + _fieldIssues.Remove(fieldKey); + } + + public void SetCrossOptionIssues(IReadOnlyList issues) + { + _crossOptionIssues.Clear(); + if (issues != null) + _crossOptionIssues.AddRange(issues); + } + + public bool HasBlockingValidationErrors() + { + if (_fieldIssues.Values.Any(list => list.Any(i => i.Severity == FieldIssueSeverity.Error))) + return true; + return _crossOptionIssues.Any(i => i.Severity == FieldIssueSeverity.Error); + } + + public IReadOnlyList GetValidationSummary() + { + var list = new List(_fieldIssues.Values.SelectMany(x => x)); + list.AddRange(_crossOptionIssues); + return list; + } + public void TrackCommit(EffectiveConfigEditCommittedArgs args) { var existing = _pending.FirstOrDefault(c => @@ -60,6 +115,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession c.SectionId == sectionId && c.OptionName == optionName); if (existing != null) _pending.Remove(existing); + ClearFieldIssues($"{sectionId}:{optionName}"); } public async Task ApplyAsync(CancellationToken ct = default) diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs index d9fd6ec..960e104 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs @@ -4,6 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; @@ -14,8 +15,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; /// public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragmentRegistry { + private static readonly IMultiValueEditBehavior DefaultMultiBehavior = new DefaultMultiValueEditBehavior(); + private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new(); private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = new(); + private readonly Dictionary<(string SectionId, string OptionName), Type> _multiDisplayComponents = new(); + private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigMultiDescriptorFactory> _multiDescriptorFactories = new(); public EffectiveConfigRenderFragmentRegistry() { @@ -83,6 +88,99 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm // local-service: dropdown (not set / net / host). RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); + + // Multi-value: server — descriptor only (behavior + validator); no custom display. + RegisterMultiDescriptor( + EffectiveConfigFieldBuilder.SectionResolver, + DnsmasqConfKeys.Server, + behavior: new ServerMultiBehavior(), + validator: new ServerMultiValidator()); + + RegisterMultiDescriptor( + EffectiveConfigFieldBuilder.SectionProcess, + DnsmasqConfKeys.ListenAddress, + validator: new ListenAddressMultiValidator()); + + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Local); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RevServer); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Address); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ResolvFile); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.RebindDomainOk); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusNxdomain); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.IgnoreAddress); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Alias); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.FilterRr); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Ipset); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Nftset); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Domain); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Cname); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.MxHost); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Srv); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.PtrRecord); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.TxtRecord); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.NaptrRecord); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.HostRecord); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DynamicHost); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.InterfaceName); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRange); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHost); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOption); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptionForce); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMatch); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpMac); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpNameMatch); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnoreNames); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsfile); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpBoot); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpIgnore); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpVendorclass); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpUserclass); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.RaParam); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Slaac); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionTftpPxe, DnsmasqConfKeys.PxeService); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.Interface); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ExceptInterface); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.AuthServer); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpInterface); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv4Interface); + RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoDhcpv6Interface); + } + + private void RegisterMultiDescriptor( + string sectionId, + string optionName, + IMultiValueEditBehavior? behavior = null, + IMultiValueOptionValidator? validator = null) + { + _multiDescriptorFactories[(sectionId, optionName)] = + (sid, name, status, getItems) => new EffectiveMultiValueConfigFieldDescriptor( + sid, + name, + status, + getItems, + behavior ?? DefaultMultiBehavior, + validator); + } + + private void RegisterMultiComponent(string sectionId, string optionName, Type componentType) + { + _multiDisplayComponents[(sectionId, optionName)] = componentType; + } + + private void RegisterMulti( + string sectionId, + string optionName, + Type componentType, + IMultiValueEditBehavior? behavior = null, + IMultiValueOptionValidator? validator = null) + { + RegisterMultiDescriptor(sectionId, optionName, behavior, validator); + RegisterMultiComponent(sectionId, optionName, componentType); } private void RegisterComponent(string sectionId, string optionName, Type componentType) @@ -100,7 +198,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm { _displayComponents[(sectionId, optionName)] = typeof(IntegerValueDisplay); _descriptorFactories[(sectionId, optionName)] = (sectionId, optionName, status, getValue, getSource, getItems) => - new EffectiveIntegerConfigFieldDescriptor(sectionId, optionName, false, status, getValue, getSource, getItems, viewSuffix, unit, min, max, defaultValue); + new EffectiveIntegerConfigFieldDescriptor(sectionId, optionName, status, getValue, getSource, getItems, viewSuffix, unit, min, max, defaultValue); + } + + private void RegisterValidatedSingle(string sectionId, string optionName, EffectiveConfigSingleValueValidator validator) + { + _descriptorFactories[(sectionId, optionName)] = + (sid, name, status, getValue, getSource, getItems) => new EffectiveConfigFieldDescriptor( + sid, + name, + false, + status, + getValue, + getSource, + getItems, + validator); } /// @@ -132,4 +244,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm builder.CloseComponent(); }; } + + /// + public RenderFragment? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback> onValuesChanged) + { + if (!_multiDisplayComponents.TryGetValue((sectionId, optionName), out var componentType)) + return null; + + return descriptor => builder => + { + builder.OpenComponent(0, componentType); + builder.AddAttribute(1, "Descriptor", descriptor); + builder.AddAttribute(2, "OnValuesChanged", onValuesChanged); + builder.CloseComponent(); + }; + } + + /// + public EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName) + { + return _multiDescriptorFactories.TryGetValue((sectionId, optionName), out var factory) ? factory : null; + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs new file mode 100644 index 0000000..45e1f98 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DefaultMultiValueEditBehavior.cs @@ -0,0 +1,11 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Default behavior for multi-value list editor: generic placeholder, allow duplicates, trim normalization. +/// +public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior +{ + public string Placeholder => "Value"; + public bool AllowDuplicates => true; + public string Normalize(string input) => (input ?? "").Trim(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs new file mode 100644 index 0000000..e8fe1ec --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs @@ -0,0 +1,58 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Evaluates cross-option rules on effective config (status merged with pending changes). +/// Returns validation issues (warnings/errors) for the UI and save guard. +/// +public static class EffectiveConfigCrossOptionEvaluator +{ + /// + /// Runs all cross-option rules and returns issues to display. Caller should pass result to . + /// + public static IReadOnlyList Evaluate( + DnsmasqServiceStatus? status, + IReadOnlyList pending) + { + var issues = new List(); + + var noResolv = GetEffectiveNoResolv(status, pending); + var serverValues = GetEffectiveServerValues(status, pending); + + // no-resolv set with no upstream servers: DNS may not work + if (noResolv && (serverValues == null || serverValues.Count == 0)) + { + var fieldKey = $"{EffectiveConfigSections.SectionResolver}:{DnsmasqConfKeys.Server}"; + issues.Add(new FieldIssue( + fieldKey, + "When no-resolv is set, add at least one server or DNS may not work.", + FieldIssueSeverity.Warning, + ItemIndex: null)); + } + + return issues; + } + + private static bool GetEffectiveNoResolv(DnsmasqServiceStatus? status, IReadOnlyList pending) + { + var fromConfig = status?.EffectiveConfig?.NoResolv ?? false; + var pendingChange = pending?.FirstOrDefault(c => + string.Equals(c.OptionName, DnsmasqConfKeys.NoResolv, StringComparison.Ordinal)); + if (pendingChange?.NewValue is bool b) + return b; + return fromConfig; + } + + private static IReadOnlyList? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList pending) + { + var fromConfig = status?.EffectiveConfig?.ServerValues; + var pendingChange = pending?.FirstOrDefault(c => + string.Equals(c.OptionName, DnsmasqConfKeys.Server, StringComparison.Ordinal)); + if (pendingChange?.NewValue is IReadOnlyList list) + return list; + return fromConfig; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs new file mode 100644 index 0000000..ab69081 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueEditBehavior.cs @@ -0,0 +1,16 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Option-specific behavior for the multi-value list editor: placeholder, duplicate policy, and normalization. +/// +public interface IMultiValueEditBehavior +{ + /// Placeholder text for the add/edit input. + string Placeholder { get; } + + /// Whether duplicate values are allowed in the list. + bool AllowDuplicates { get; } + + /// Normalizes raw input (e.g. trim, lowercase for domains). + string Normalize(string input); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueOptionValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueOptionValidator.cs new file mode 100644 index 0000000..6b4b2b6 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/IMultiValueOptionValidator.cs @@ -0,0 +1,15 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Option-specific validation for a single item in a multi-value list. +/// +public interface IMultiValueOptionValidator +{ + /// + /// Validates one normalized value. Returns an error message if invalid, or null if valid. + /// + /// The normalized string (e.g. after behavior.Normalize). + /// Current full list (for duplicate or context checks). + /// Index being edited, or null when adding a new item. + string? ValidateItem(string normalized, IReadOnlyList current, int? editIndex = null); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs new file mode 100644 index 0000000..6c8ee84 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ListenAddressMultiValidator.cs @@ -0,0 +1,23 @@ +using System.Net; +using System.Net.Sockets; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Validates listen-address option values: IPv4 or IPv6 address only. +/// +public sealed class ListenAddressMultiValidator : IMultiValueOptionValidator +{ + public string? ValidateItem(string normalized, IReadOnlyList current, int? editIndex = null) + { + var v = (normalized ?? "").Trim(); + if (string.IsNullOrEmpty(v)) + return null; + if (!IPAddress.TryParse(v, out var ip)) + return $"Invalid value for listen-address: '{normalized}'."; + if (ip.AddressFamily != AddressFamily.InterNetwork && + ip.AddressFamily != AddressFamily.InterNetworkV6) + return $"Invalid value for listen-address: '{normalized}'."; + return null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs new file mode 100644 index 0000000..ff1ae3c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiBehavior.cs @@ -0,0 +1,12 @@ +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Edit behavior for the server multi-value option: placeholder, no duplicates, normalize (trim). +/// +public sealed class ServerMultiBehavior : IMultiValueEditBehavior +{ + public string Placeholder => "IP or hostname (e.g. 8.8.8.8 or dns.example.com)"; + public bool AllowDuplicates => false; + + public string Normalize(string input) => (input ?? "").Trim(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs new file mode 100644 index 0000000..95a735c --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/ServerMultiValidator.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Validates server option values: IP address or hostname. Duplicate check is done by the editor when AllowDuplicates is false. +/// +public sealed class ServerMultiValidator : IMultiValueOptionValidator +{ + private static readonly Regex HostnameRegex = new( + @"^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public string? ValidateItem(string normalized, IReadOnlyList current, int? editIndex = null) + { + if (string.IsNullOrWhiteSpace(normalized)) + return "Server value cannot be empty."; + + if (IPAddress.TryParse(normalized, out var ip)) + { + if (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6) + return "Invalid IP address."; + return null; + } + + if (HostnameRegex.IsMatch(normalized) && normalized.Length <= 253) + return null; + + return "Enter a valid IP address or hostname."; + } +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/EffectiveConfigDescriptorFactory.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/EffectiveConfigDescriptorFactory.cs index 99d223f..ee8d495 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/EffectiveConfigDescriptorFactory.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/EffectiveConfigDescriptorFactory.cs @@ -14,3 +14,13 @@ public delegate IEffectiveConfigFieldDescriptor EffectiveConfigDescriptorFactory Func? getValue, Func? getSource, Func?>? getItems); + +/// +/// Creates an for a multi-value field (section, option, status, getItems only). +/// Used by the registry so the builder can create multi descriptors with behavior/validator. +/// +public delegate EffectiveMultiValueConfigFieldDescriptor EffectiveConfigMultiDescriptorFactory( + string sectionId, + string optionName, + DnsmasqServiceStatus? status, + Func?>? getItems); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/IEffectiveConfigFieldDescriptor.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/IEffectiveConfigFieldDescriptor.cs index 5e2c383..6770f6e 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/IEffectiveConfigFieldDescriptor.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/Abstractions/IEffectiveConfigFieldDescriptor.cs @@ -13,4 +13,6 @@ public interface IEffectiveConfigFieldDescriptor object? GetValue(); ConfigValueSource? GetSource(); IReadOnlyList? GetItems(); + /// Validates the value; returns error message or null if valid. Used for single/flag fields. + string? Validate(object? value); } diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDescriptor.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDescriptor.cs index 11d8cd1..eae0a9a 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDescriptor.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDescriptor.cs @@ -3,6 +3,9 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions; namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +/// Optional validation for a single-value field. Returns error message or null if valid. +public delegate string? EffectiveConfigSingleValueValidator(object? value); + /// /// Glue for rendering one effective-config field: metadata (section, option name) plus delegates that resolve value, source, and items from Status at render time. /// @@ -13,10 +16,12 @@ public record EffectiveConfigFieldDescriptor( DnsmasqServiceStatus? Status, Func? ResolveValue, Func? ResolveSource, - Func?>? ResolveItems + Func?>? ResolveItems, + EffectiveConfigSingleValueValidator? ValidateValue = null ) : IEffectiveConfigFieldDescriptor { public object? GetValue() => ResolveValue?.Invoke(Status); public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status); public IReadOnlyList? GetItems() => ResolveItems?.Invoke(Status); + public string? Validate(object? value) => ValidateValue?.Invoke(value); } diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveIntegerConfigFieldDescriptor.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveIntegerConfigFieldDescriptor.cs index 5363cb2..f58ebbd 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveIntegerConfigFieldDescriptor.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveIntegerConfigFieldDescriptor.cs @@ -8,7 +8,6 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; public record EffectiveIntegerConfigFieldDescriptor( string SectionId, string OptionName, - bool IsMultiValue, DnsmasqServiceStatus? Status, Func? ResolveValue, Func? ResolveSource, @@ -18,4 +17,20 @@ public record EffectiveIntegerConfigFieldDescriptor( int Min = 0, int Max = int.MaxValue, int? DefaultValue = null -) : EffectiveConfigFieldDescriptor(SectionId, OptionName, IsMultiValue, Status, ResolveValue, ResolveSource, ResolveItems); +) : EffectiveConfigFieldDescriptor( + SectionId, + OptionName, + false, + Status, + ResolveValue, + ResolveSource, + ResolveItems, + value => + { + if (value is null) return null; + if (value is int n && (n < Min || n > Max)) + return $"Value must be between {Min} and {Max}."; + if (value is int) + return null; + return $"Value must be a whole number between {Min} and {Max}."; + }); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveMultiValueConfigFieldDescriptor.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveMultiValueConfigFieldDescriptor.cs new file mode 100644 index 0000000..cf63afe --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveMultiValueConfigFieldDescriptor.cs @@ -0,0 +1,24 @@ +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// +/// Descriptor for multi-value options with option-specific edit behavior and validation. +/// Behavior and validator travel with the descriptor, same as integer metadata in . +/// +public record EffectiveMultiValueConfigFieldDescriptor( + string SectionId, + string OptionName, + DnsmasqServiceStatus? Status, + Func?>? ResolveItems, + IMultiValueEditBehavior Behavior, + IMultiValueOptionValidator? Validator = null +) : EffectiveConfigFieldDescriptor( + SectionId, + OptionName, + true, + Status, + null, + null, + ResolveItems); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/FieldIssue.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/FieldIssue.cs new file mode 100644 index 0000000..e17adec --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/FieldIssue.cs @@ -0,0 +1,19 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// +/// Severity of a validation issue for an effective-config field. +/// +public enum FieldIssueSeverity +{ + Warning, + Error +} + +/// +/// A single validation issue for a field (inline or cross-option). Errors block save; warnings can be confirmed. +/// +/// Key identifying the field (e.g. sectionId:optionName). +/// User-facing message. +/// Warning or Error. +/// Optional 0-based index for multi-value item-level issues. +public sealed record FieldIssue(string FieldKey, string Message, FieldIssueSeverity Severity, int? ItemIndex = null);