mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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.
This commit is contained in:
@@ -120,7 +120,7 @@ public class EffectiveDnsmasqConfigTests
|
||||
FastDnsRetry: null,
|
||||
DhcpScriptPath: null,
|
||||
MxTarget: null,
|
||||
Conntrack: null
|
||||
Conntrack: false
|
||||
);
|
||||
|
||||
[Fact]
|
||||
|
||||
+13
-1
@@ -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 @@
|
||||
}
|
||||
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
ValidationErrorCount="@_validationErrorCount" ValidationWarningCount="@_validationWarningCount"
|
||||
AllSectionsExpanded="@_allSectionsExpanded"
|
||||
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||
<CascadingValue Value="@Session">
|
||||
@@ -40,6 +42,9 @@
|
||||
private HashSet<string> _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;
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
@PendingChangesCount change(s)
|
||||
</span>
|
||||
}
|
||||
@if (ValidationErrorCount > 0 || ValidationWarningCount > 0)
|
||||
{
|
||||
<span class="small ec-toolbar-validation-count" aria-live="polite">
|
||||
@if (ValidationErrorCount > 0)
|
||||
{
|
||||
<span class="text-danger fw-medium">errors: @ValidationErrorCount</span>
|
||||
}
|
||||
@if (ValidationErrorCount > 0 && ValidationWarningCount > 0) { <span class="text-muted">·</span> }
|
||||
@if (ValidationWarningCount > 0)
|
||||
{
|
||||
<span class="text-warning">warnings: @ValidationWarningCount</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -46,6 +60,8 @@
|
||||
[Parameter] public EventCallback<string> 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; }
|
||||
|
||||
+13
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-24
@@ -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. *@
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
|
||||
if (customMulti is not null)
|
||||
{
|
||||
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
|
||||
<li class="ec-field @(IsEditMode && IsActive ? "ec-field-editing" : "")">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
<CascadingValue Value="@(IsEditMode && IsActive)">
|
||||
<CascadingValue Value="@IsEditMode">
|
||||
@customMulti(Descriptor)
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</span>
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive)"
|
||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
}
|
||||
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<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
|
||||
+132
-43
@@ -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
|
||||
|
||||
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<ol class="ec-field-value-list">
|
||||
@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);
|
||||
|
||||
<li class="ec-multi-list-row" @key="anchorId">
|
||||
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input class="form-control form-control-sm ec-multi-list-input @(rowInlineError != null ? "is-invalid" : "")"
|
||||
placeholder="@(Behavior?.Placeholder ?? "")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
@if (rowInlineError != null)
|
||||
{
|
||||
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@rowInlineError</div>
|
||||
}
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -48,6 +59,7 @@
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
PreventBlurOnMouseDown="true"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
}
|
||||
</span>
|
||||
@@ -64,20 +76,28 @@
|
||||
|
||||
@if (AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
|
||||
@if (_addingNew)
|
||||
{
|
||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="New value"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
<div class="ec-multi-list-row-controls">
|
||||
<div class="ec-multi-list-input-group">
|
||||
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input @(AddInlineError != null ? "is-invalid" : "")"
|
||||
placeholder="@(Behavior?.Placeholder ?? "New value")"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
@if (AddInlineError != null)
|
||||
{
|
||||
<div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@AddInlineError</div>
|
||||
}
|
||||
</div>
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -88,7 +108,7 @@
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? 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<IReadOnlyList<string>> 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<ValueWithSource> _displayItems = new();
|
||||
private IReadOnlyList<string> _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<ValueWithSource>()).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<ValueWithSource>())
|
||||
.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<ValueWithSource>())
|
||||
.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<ValueWithSource>())
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -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;
|
||||
}
|
||||
|
||||
+8
-2
@@ -12,6 +12,9 @@
|
||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||
IsEditMode="@IsEditMode"
|
||||
AllowAddManagedValue="@IsEditMode"
|
||||
FieldKey="@FieldKey"
|
||||
Behavior="@(_multiDesc?.Behavior)"
|
||||
Validator="@(_multiDesc?.Validator)"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
@@ -20,7 +23,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
<div class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@@ -37,12 +40,13 @@
|
||||
<OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true"
|
||||
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" />
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+6
-3
@@ -22,7 +22,7 @@ public static class EffectiveConfigFieldDescriptorListExtensions
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static void AddMultiDescriptor(
|
||||
this List<EffectiveConfigFieldDescriptor> list,
|
||||
IEffectiveConfigRenderFragmentRegistry? registry,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Complete the channel after waiting for streams; used when stream-close wait timed out or caller is shutting down.</summary>
|
||||
private void CompleteChannelIfNeeded()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_channelCompleted) return;
|
||||
_channelCompleted = true;
|
||||
_channel.Writer.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ProcessOutputLine> 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);
|
||||
|
||||
|
||||
+15
-1
@@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
@@ -13,6 +13,9 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
|
||||
string? ActiveFieldKey { get; }
|
||||
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; }
|
||||
|
||||
/// <summary>Per-field validation issues (errors block save; warnings can be confirmed).</summary>
|
||||
IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> 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<FieldIssue> issues);
|
||||
void ClearFieldIssues(string fieldKey);
|
||||
|
||||
/// <summary>Replaces all cross-option validation issues (e.g. no-resolv vs server). Merged with per-field issues for display and save guard.</summary>
|
||||
void SetCrossOptionIssues(IReadOnlyList<FieldIssue> issues);
|
||||
|
||||
/// <summary>True if any field has one or more issues with <see cref="FieldIssueSeverity.Error"/>.</summary>
|
||||
bool HasBlockingValidationErrors();
|
||||
/// <summary>All issues across fields for summary display (e.g. toolbar count or save guard message).</summary>
|
||||
IReadOnlyList<FieldIssue> GetValidationSummary();
|
||||
|
||||
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+10
@@ -27,4 +27,14 @@ public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton
|
||||
/// Returns a factory that creates the correct descriptor type for this field (e.g. <see cref="EffectiveIntegerConfigFieldDescriptor"/>), or null to use the default.
|
||||
/// </summary>
|
||||
EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fragment that renders the custom multi-value component for this field, or null to use the generic list editor.
|
||||
/// </summary>
|
||||
RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a factory that creates a multi-value descriptor (e.g. <see cref="EffectiveMultiValueConfigFieldDescriptor"/>), or null to use the plain descriptor.
|
||||
/// </summary>
|
||||
EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName);
|
||||
}
|
||||
|
||||
+56
@@ -7,6 +7,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
|
||||
{
|
||||
private readonly IEffectiveConfigSaveService _saveService;
|
||||
private readonly List<PendingEffectiveConfigChange> _pending = new();
|
||||
private readonly Dictionary<string, List<FieldIssue>> _fieldIssues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<FieldIssue> _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<PendingEffectiveConfigChange> PendingChanges => _pending;
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<FieldIssue>> FieldIssues
|
||||
{
|
||||
get
|
||||
{
|
||||
var copy = new Dictionary<string, List<FieldIssue>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kv in _fieldIssues)
|
||||
copy[kv.Key] = new List<FieldIssue>(kv.Value);
|
||||
foreach (var issue in _crossOptionIssues)
|
||||
{
|
||||
if (!copy.TryGetValue(issue.FieldKey, out var list))
|
||||
copy[issue.FieldKey] = list = new List<FieldIssue>();
|
||||
list.Add(issue);
|
||||
}
|
||||
return copy.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<FieldIssue>)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<FieldIssue> 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<FieldIssue> 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<FieldIssue> GetValidationSummary()
|
||||
{
|
||||
var list = new List<FieldIssue>(_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<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
|
||||
|
||||
+134
-1
@@ -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;
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -132,4 +244,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
||||
builder.CloseComponent();
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> 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();
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EffectiveConfigMultiDescriptorFactory? GetMultiDescriptorFactory(string sectionId, string optionName)
|
||||
{
|
||||
return _multiDescriptorFactories.TryGetValue((sectionId, optionName), out var factory) ? factory : null;
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Default behavior for multi-value list editor: generic placeholder, allow duplicates, trim normalization.
|
||||
/// </summary>
|
||||
public sealed class DefaultMultiValueEditBehavior : IMultiValueEditBehavior
|
||||
{
|
||||
public string Placeholder => "Value";
|
||||
public bool AllowDuplicates => true;
|
||||
public string Normalize(string input) => (input ?? "").Trim();
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates cross-option rules on effective config (status merged with pending changes).
|
||||
/// Returns validation issues (warnings/errors) for the UI and save guard.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigCrossOptionEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs all cross-option rules and returns issues to display. Caller should pass result to <see cref="IEffectiveConfigEditSession.SetCrossOptionIssues"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<FieldIssue> Evaluate(
|
||||
DnsmasqServiceStatus? status,
|
||||
IReadOnlyList<PendingEffectiveConfigChange> pending)
|
||||
{
|
||||
var issues = new List<FieldIssue>();
|
||||
|
||||
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<PendingEffectiveConfigChange> 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<string>? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList<PendingEffectiveConfigChange> pending)
|
||||
{
|
||||
var fromConfig = status?.EffectiveConfig?.ServerValues;
|
||||
var pendingChange = pending?.FirstOrDefault(c =>
|
||||
string.Equals(c.OptionName, DnsmasqConfKeys.Server, StringComparison.Ordinal));
|
||||
if (pendingChange?.NewValue is IReadOnlyList<string> list)
|
||||
return list;
|
||||
return fromConfig;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Option-specific behavior for the multi-value list editor: placeholder, duplicate policy, and normalization.
|
||||
/// </summary>
|
||||
public interface IMultiValueEditBehavior
|
||||
{
|
||||
/// <summary>Placeholder text for the add/edit input.</summary>
|
||||
string Placeholder { get; }
|
||||
|
||||
/// <summary>Whether duplicate values are allowed in the list.</summary>
|
||||
bool AllowDuplicates { get; }
|
||||
|
||||
/// <summary>Normalizes raw input (e.g. trim, lowercase for domains).</summary>
|
||||
string Normalize(string input);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Option-specific validation for a single item in a multi-value list.
|
||||
/// </summary>
|
||||
public interface IMultiValueOptionValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates one normalized value. Returns an error message if invalid, or null if valid.
|
||||
/// </summary>
|
||||
/// <param name="normalized">The normalized string (e.g. after behavior.Normalize).</param>
|
||||
/// <param name="current">Current full list (for duplicate or context checks).</param>
|
||||
/// <param name="editIndex">Index being edited, or null when adding a new item.</param>
|
||||
string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates listen-address option values: IPv4 or IPv6 address only.
|
||||
/// </summary>
|
||||
public sealed class ListenAddressMultiValidator : IMultiValueOptionValidator
|
||||
{
|
||||
public string? ValidateItem(string normalized, IReadOnlyList<string> 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;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Edit behavior for the server multi-value option: placeholder, no duplicates, normalize (trim).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates server option values: IP address or hostname. Duplicate check is done by the editor when AllowDuplicates is false.
|
||||
/// </summary>
|
||||
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<string> 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.";
|
||||
}
|
||||
}
|
||||
+10
@@ -14,3 +14,13 @@ public delegate IEffectiveConfigFieldDescriptor EffectiveConfigDescriptorFactory
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="EffectiveMultiValueConfigFieldDescriptor"/> for a multi-value field (section, option, status, getItems only).
|
||||
/// Used by the registry so the builder can create multi descriptors with behavior/validator.
|
||||
/// </summary>
|
||||
public delegate EffectiveMultiValueConfigFieldDescriptor EffectiveConfigMultiDescriptorFactory(
|
||||
string sectionId,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems);
|
||||
|
||||
+2
@@ -13,4 +13,6 @@ public interface IEffectiveConfigFieldDescriptor
|
||||
object? GetValue();
|
||||
ConfigValueSource? GetSource();
|
||||
IReadOnlyList<ValueWithSource>? GetItems();
|
||||
/// <summary>Validates the value; returns error message or null if valid. Used for single/flag fields.</summary>
|
||||
string? Validate(object? value);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>Optional validation for a single-value field. Returns error message or null if valid.</summary>
|
||||
public delegate string? EffectiveConfigSingleValueValidator(object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Glue for rendering one effective-config field: metadata (section, option name) plus delegates that resolve value, source, and items from Status at render time.
|
||||
/// </summary>
|
||||
@@ -13,10 +16,12 @@ public record EffectiveConfigFieldDescriptor(
|
||||
DnsmasqServiceStatus? Status,
|
||||
Func<DnsmasqServiceStatus?, object?>? ResolveValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems,
|
||||
EffectiveConfigSingleValueValidator? ValidateValue = null
|
||||
) : IEffectiveConfigFieldDescriptor
|
||||
{
|
||||
public object? GetValue() => ResolveValue?.Invoke(Status);
|
||||
public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status);
|
||||
public IReadOnlyList<ValueWithSource>? GetItems() => ResolveItems?.Invoke(Status);
|
||||
public string? Validate(object? value) => ValidateValue?.Invoke(value);
|
||||
}
|
||||
|
||||
+17
-2
@@ -8,7 +8,6 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
public record EffectiveIntegerConfigFieldDescriptor(
|
||||
string SectionId,
|
||||
string OptionName,
|
||||
bool IsMultiValue,
|
||||
DnsmasqServiceStatus? Status,
|
||||
Func<DnsmasqServiceStatus?, object?>? ResolveValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? 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}.";
|
||||
});
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Descriptor for multi-value options with option-specific edit behavior and validation.
|
||||
/// Behavior and validator travel with the descriptor, same as integer metadata in <see cref="EffectiveIntegerConfigFieldDescriptor"/>.
|
||||
/// </summary>
|
||||
public record EffectiveMultiValueConfigFieldDescriptor(
|
||||
string SectionId,
|
||||
string OptionName,
|
||||
DnsmasqServiceStatus? Status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems,
|
||||
IMultiValueEditBehavior Behavior,
|
||||
IMultiValueOptionValidator? Validator = null
|
||||
) : EffectiveConfigFieldDescriptor(
|
||||
SectionId,
|
||||
OptionName,
|
||||
true,
|
||||
Status,
|
||||
null,
|
||||
null,
|
||||
ResolveItems);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Severity of a validation issue for an effective-config field.
|
||||
/// </summary>
|
||||
public enum FieldIssueSeverity
|
||||
{
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single validation issue for a field (inline or cross-option). Errors block save; warnings can be confirmed.
|
||||
/// </summary>
|
||||
/// <param name="FieldKey">Key identifying the field (e.g. sectionId:optionName).</param>
|
||||
/// <param name="Message">User-facing message.</param>
|
||||
/// <param name="Severity">Warning or Error.</param>
|
||||
/// <param name="ItemIndex">Optional 0-based index for multi-value item-level issues.</param>
|
||||
public sealed record FieldIssue(string FieldKey, string Message, FieldIssueSeverity Severity, int? ItemIndex = null);
|
||||
Reference in New Issue
Block a user