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:
2026-03-07 21:11:12 +10:00
parent a9f8961c32
commit ff94be87b2
28 changed files with 750 additions and 106 deletions
@@ -120,7 +120,7 @@ public class EffectiveDnsmasqConfigTests
FastDnsRetry: null, FastDnsRetry: null,
DhcpScriptPath: null, DhcpScriptPath: null,
MxTarget: null, MxTarget: null,
Conntrack: null Conntrack: false
); );
[Fact] [Fact]
@@ -3,6 +3,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IEffectiveConfigEditSession Session @inject IEffectiveConfigEditSession Session
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@@ -17,6 +18,7 @@
} }
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged" <EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count" IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
ValidationErrorCount="@_validationErrorCount" ValidationWarningCount="@_validationWarningCount"
AllSectionsExpanded="@_allSectionsExpanded" AllSectionsExpanded="@_allSectionsExpanded"
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" /> OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
<CascadingValue Value="@Session"> <CascadingValue Value="@Session">
@@ -40,6 +42,9 @@
private HashSet<string> _openPanels = new(); private HashSet<string> _openPanels = new();
private bool _showSaveModal; 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? _optionHelpKey;
private string? _optionHelpAnchorId; private string? _optionHelpAnchorId;
private string _optionHelpTitle = "Option help"; private string _optionHelpTitle = "Option help";
@@ -69,7 +74,7 @@
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; }, ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); }, DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }, 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) private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{ {
Session.TrackCommit(args); Session.TrackCommit(args);
RunCrossOptionEvaluator();
StateHasChanged(); StateHasChanged();
} }
private void RunCrossOptionEvaluator()
{
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
Session.SetCrossOptionIssues(issues);
}
private void OpenSaveModal() private void OpenSaveModal()
{ {
if (Session.PendingChanges.Count == 0) return; if (Session.PendingChanges.Count == 0) return;
@@ -30,6 +30,20 @@
@PendingChangesCount change(s) @PendingChangesCount change(s)
</span> </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 else
{ {
@@ -46,6 +60,8 @@
[Parameter] public EventCallback<string> SearchTermChanged { get; set; } [Parameter] public EventCallback<string> SearchTermChanged { get; set; }
[Parameter] public bool IsEditMode { get; set; } [Parameter] public bool IsEditMode { get; set; }
[Parameter] public int PendingChangesCount { 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 bool AllSectionsExpanded { get; set; }
[Parameter] public EventCallback OnExpandAll { get; set; } [Parameter] public EventCallback OnExpandAll { get; set; }
[Parameter] public EventCallback OnCollapseAll { get; set; } [Parameter] public EventCallback OnCollapseAll { get; set; }
@@ -34,9 +34,20 @@ else
private async Task OnInput(ChangeEventArgs e) private async Task OnInput(ChangeEventArgs e)
{ {
int? value = null; var raw = e.Value?.ToString();
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max) object? value = null;
if (string.IsNullOrWhiteSpace(raw))
{
value = null;
}
else if (int.TryParse(raw, out var num))
{
value = num; value = num;
}
else
{
value = raw;
}
await NotifyValueChanged(value); await NotifyValueChanged(value);
} }
} }
@@ -5,8 +5,6 @@
@using DnsmasqWebUI.Infrastructure.Helpers.Config @using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions @using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@using System.Net
@using System.Net.Sockets
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry @inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject INotificationService NotificationService @inject INotificationService NotificationService
@@ -44,11 +42,32 @@
} }
else if (Descriptor.IsMultiValue) 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<IReadOnlyList<string>>(this, HandleMultiValueChanged));
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" if (customMulti is not null)
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" {
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey" @* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" /> <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 else
{ {
@@ -191,7 +210,7 @@ else
await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName); await Ui.RevertFieldAsync(Descriptor.SectionId, Descriptor.OptionName);
return true; return true;
} }
var validationError = ValidateDraftValue(newValue); var validationError = Descriptor.Validate(newValue);
if (validationError != null) if (validationError != null)
{ {
NotificationService.ShowError(validationError); NotificationService.ShowError(validationError);
@@ -261,22 +280,6 @@ else
await Ui.DeactivateFieldAsync(); 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) private static IReadOnlyList<string>? AsStringList(object? value)
{ {
return value as IReadOnlyList<string>; return value as IReadOnlyList<string>;
@@ -2,33 +2,44 @@
@using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IJSRuntime JSRuntime @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"> <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 anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true; var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i; var rowIndex = i;
var rowValue = item.Value; 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) @if (_editingIndex == rowIndex && !itemReadonly)
{ {
<input class="form-control form-control-sm ec-multi-list-input" <div class="ec-multi-list-row-controls">
@bind="_draftValue" @bind:event="oninput" /> <div class="ec-multi-list-input-group">
<span class="status-badge-inline"> <input class="form-control form-control-sm ec-multi-list-input @(rowInlineError != null ? "is-invalid" : "")"
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true" placeholder="@(Behavior?.Placeholder ?? "")"
Title="Apply change" AriaLabel="Apply change" @bind="_draftValue" @bind:event="oninput" />
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" /> @if (rowInlineError != null)
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true" {
Title="Discard edit" AriaLabel="Cancel edit" <div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@rowInlineError</div>
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" /> }
</span> </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 else
{ {
@@ -48,6 +59,7 @@
{ {
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true" <StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove" Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" /> OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
} }
</span> </span>
@@ -64,20 +76,28 @@
@if (AllowAddManagedValue) @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) @if (_addingNew)
{ {
<input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input" <div class="ec-multi-list-row-controls">
placeholder="New value" <div class="ec-multi-list-input-group">
@bind="_draftValue" @bind:event="oninput" /> <input @ref="_addNewInputRef" class="form-control form-control-sm ec-multi-list-input @(AddInlineError != null ? "is-invalid" : "")"
<span class="status-badge-inline"> placeholder="@(Behavior?.Placeholder ?? "New value")"
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true" @bind="_draftValue" @bind:event="oninput" />
Title="Add this value" AriaLabel="Add" @if (AddInlineError != null)
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" /> {
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true" <div class="ec-multi-list-invalid-feedback invalid-feedback d-block">@AddInlineError</div>
Title="Discard" AriaLabel="Cancel" }
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" /> </div>
</span> <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 else
{ {
@@ -88,7 +108,7 @@
</li> </li>
} }
</ol> </ol>
</span> </div>
@code { @code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; } [Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
@@ -98,6 +118,10 @@
[Parameter] public bool IsEditMode { get; set; } [Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool AllowAddManagedValue { get; set; } = true; [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; } [Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item"; private const string AddItemKey = "add-item";
@@ -107,9 +131,22 @@
private bool _didAutoOpenAdd; private bool _didAutoOpenAdd;
private bool _shouldFocusAddInput; private bool _shouldFocusAddInput;
private ElementReference _addNewInputRef; 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() protected override void OnParametersSet()
{ {
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue) if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
{ {
_didAutoOpenAdd = true; _didAutoOpenAdd = true;
@@ -139,6 +176,7 @@
_addingNew = false; _addingNew = false;
_editingIndex = index; _editingIndex = index;
_draftValue = current; _draftValue = current;
ClearInlineError();
} }
private void BeginAdd() private void BeginAdd()
@@ -147,6 +185,7 @@
_addingNew = true; _addingNew = true;
_draftValue = ""; _draftValue = "";
_shouldFocusAddInput = true; _shouldFocusAddInput = true;
ClearInlineError();
} }
private void CancelInline() private void CancelInline()
@@ -154,46 +193,96 @@
_editingIndex = null; _editingIndex = null;
_addingNew = false; _addingNew = false;
_draftValue = ""; _draftValue = "";
ClearInlineError();
} }
private async Task SaveEdit(int index) private async Task SaveEdit(int index)
{ {
var next = (SourceItems ?? Array.Empty<ValueWithSource>()) var current = _currentValues;
.Select(x => x.Value) var next = current.ToList();
.ToList();
if (index < 0 || index >= next.Count) return; 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); await OnValuesChanged.InvokeAsync(next);
CancelInline(); CancelInline();
} }
private async Task SaveNew() private async Task SaveNew()
{ {
var value = (_draftValue ?? "").Trim(); var raw = _draftValue ?? "";
if (value.Length == 0) return; var normalized = Behavior?.Normalize(raw) ?? raw.Trim();
if (normalized.Length == 0) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>()) var current = _currentValues;
.Select(x => x.Value) var validationError = Validator?.ValidateItem(normalized, current, null);
.ToList(); 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); await OnValuesChanged.InvokeAsync(next);
CancelInline(); 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) private async Task RemoveAt(int index)
{ {
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return; if (index < 0 || index >= _displayItems.Count) return;
var item = SourceItems[index]; var item = _displayItems[index];
if (item?.Source?.IsReadOnly == true) return; if (item?.Source?.IsReadOnly == true) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>()) var next = _displayItems.Select(x => x.Value).ToList();
.Select(x => x.Value)
.ToList();
if (index < 0 || index >= next.Count) return; if (index < 0 || index >= next.Count) return;
next.RemoveAt(index); next.RemoveAt(index);
_displayItems.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next); await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
} }
} }
@@ -20,6 +20,33 @@
min-height: 2rem; 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 { .ec-multi-list-row .ec-field-value-item {
min-width: 0; min-width: 0;
} }
@@ -32,8 +59,9 @@
} }
.ec-multi-list-row .form-control.ec-multi-list-input { .ec-multi-list-row .form-control.ec-multi-list-input {
max-width: 28rem; max-width: none;
flex-shrink: 0; flex-shrink: 0;
width: 100%;
} }
.ec-multi-list-empty-hint { .ec-multi-list-empty-hint {
@@ -51,11 +79,13 @@
} }
.ec-multi-list-add-item .form-control.ec-multi-list-input { .ec-multi-list-add-item .form-control.ec-multi-list-input {
flex: 1 1 auto;
max-width: none;
min-width: 12rem; min-width: 12rem;
} }
.ec-multi-list-add-item .ec-multi-list-input-group {
max-width: none;
}
.ec-multi-list-add-item .status-badge-inline { .ec-multi-list-add-item .status-badge-inline {
margin-left: auto; margin-left: auto;
} }
@@ -12,6 +12,9 @@
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")" ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode" IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode" AllowAddManagedValue="@IsEditMode"
FieldKey="@FieldKey"
Behavior="@(_multiDesc?.Behavior)"
Validator="@(_multiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" /> OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items" <OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
@@ -20,7 +23,7 @@
} }
else else
{ {
<span class="ec-field-value ec-field-value-multi"> <div class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0) @if (Items?.Count > 0)
{ {
<ol class="ec-field-value-list"> <ol class="ec-field-value-list">
@@ -37,12 +40,13 @@
<OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true" <OptionStateBadge OptionName="@Label" ShowEditableBadge="true" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" /> FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" />
} }
</span> </div>
} }
</li> </li>
@code { @code {
[Parameter] public string Label { get; set; } = ""; [Parameter] public string Label { get; set; } = "";
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; } [Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; } [Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public string? LabelTooltip { get; set; } [Parameter] public string? LabelTooltip { get; set; }
@@ -58,4 +62,6 @@
[Parameter] public EventCallback OnConfirmRequested { get; set; } [Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; } [Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; } [Parameter] public EventCallback OnRevertRequested { get; set; }
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
} }
@@ -64,6 +64,11 @@
private async Task HandleSave() private async Task HandleSave()
{ {
if (Session.PendingChanges.Count == 0) return; if (Session.PendingChanges.Count == 0) return;
if (Session.HasBlockingValidationErrors())
{
NotificationService.ShowError("Fix validation errors before saving.");
return;
}
_saveState = SaveUiState.Saving; _saveState = SaveUiState.Saving;
_saveResult = null; _saveResult = null;
@@ -114,7 +119,7 @@
_saveState = SaveUiState.Review; _saveState = SaveUiState.Review;
_saveResult = null; _saveResult = null;
_restoreResult = null; _restoreResult = null;
await OnClose.InvokeAsync(); await OnCloseAndEndEdit.InvokeAsync();
await OnSaveCompleted.InvokeAsync(); await OnSaveCompleted.InvokeAsync();
return; return;
} }
@@ -22,7 +22,7 @@ public static class EffectiveConfigFieldDescriptorListExtensions
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems) Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{ {
if (EffectiveConfigOptionKindMap.GetKind(optionName) == EffectiveConfigFieldKind.Multi) if (EffectiveConfigOptionKindMap.GetKind(optionName) == EffectiveConfigFieldKind.Multi)
list.AddMultiDescriptor(optionName, status, getItems); list.AddMultiDescriptor(registry, optionName, status, getItems);
else else
list.AddSingleDescriptor(registry, optionName, status, getValue, getSource, getItems); list.AddSingleDescriptor(registry, optionName, status, getValue, getSource, getItems);
} }
@@ -43,15 +43,18 @@ public static class EffectiveConfigFieldDescriptorListExtensions
} }
/// <summary> /// <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> /// </summary>
public static void AddMultiDescriptor( public static void AddMultiDescriptor(
this List<EffectiveConfigFieldDescriptor> list, this List<EffectiveConfigFieldDescriptor> list,
IEffectiveConfigRenderFragmentRegistry? registry,
string optionName, string optionName,
DnsmasqServiceStatus? status, DnsmasqServiceStatus? status,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems) Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
{ {
var sectionId = EffectiveConfigSections.GetSectionId(optionName); 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; _logger = logger;
process.EnableRaisingEvents = true; process.EnableRaisingEvents = true;
process.Exited += (_, _) => // 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).
lock (_gate)
{
if (!_channelCompleted)
{
_channelCompleted = true;
_channel.Writer.Complete();
}
}
};
process.OutputDataReceived += (_, e) => process.OutputDataReceived += (_, e) =>
{ {
@@ -52,7 +43,10 @@ internal sealed class ProcessHandle : IProcessHandle
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow)); _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow));
} }
else else
{
_stdoutClosed.TrySetResult(); _stdoutClosed.TrySetResult();
TryCompleteChannelWhenBothStreamsClosed();
}
}; };
process.ErrorDataReceived += (_, e) => process.ErrorDataReceived += (_, e) =>
@@ -63,10 +57,35 @@ internal sealed class ProcessHandle : IProcessHandle
_channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow)); _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow));
} }
else else
{
_stderrClosed.TrySetResult(); _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) public async IAsyncEnumerable<ProcessOutputLine> ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default)
{ {
await foreach (var line in _channel.Reader.ReadAllAsync(ct)) await foreach (var line in _channel.Reader.ReadAllAsync(ct))
@@ -100,24 +119,18 @@ internal sealed class ProcessHandle : IProcessHandle
var timeoutSeconds = timeout?.TotalSeconds ?? 0; var timeoutSeconds = timeout?.TotalSeconds ?? 0;
_logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds); _logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds);
try { _process.Kill(); } catch { /* best effort */ } try { _process.Kill(); } catch { /* best effort */ }
lock (_gate)
{
if (!_channelCompleted)
{
_channelCompleted = true;
_channel.Writer.Complete();
}
}
try { await drainTask; } catch (OperationCanceledException) { }
await WaitForStreamsClosedAsync(); await WaitForStreamsClosedAsync();
CompleteChannelIfNeeded();
try { await drainTask; } catch (OperationCanceledException) { }
var err = _stderr.ToString(); var err = _stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n"; if (!string.IsNullOrEmpty(err)) err += "\n";
err += $"Command timed out after {timeoutSeconds} seconds."; err += $"Command timed out after {timeoutSeconds} seconds.";
return new ProcessRunResult(null, _stdout.ToString(), err, true); return new ProcessRunResult(null, _stdout.ToString(), err, true);
} }
try { await drainTask; } catch (OperationCanceledException) { }
await WaitForStreamsClosedAsync(); await WaitForStreamsClosedAsync();
CompleteChannelIfNeeded();
try { await drainTask; } catch (OperationCanceledException) { }
var exitCode = _process.HasExited ? _process.ExitCode : -1; var exitCode = _process.HasExited ? _process.ExitCode : -1;
_logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode); _logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode);
@@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary> /// <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. /// Components read state from here and call methods; the owning section re-renders after state changes.
/// </summary> /// </summary>
public interface IEffectiveConfigEditSession : IApplicationScopedService public interface IEffectiveConfigEditSession : IApplicationScopedService
@@ -13,6 +13,9 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
string? ActiveFieldKey { get; } string? ActiveFieldKey { get; }
IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { 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 EnterEditMode();
void ExitEditModeDiscard(); void ExitEditModeDiscard();
void ActivateField(string fieldKey); void ActivateField(string fieldKey);
@@ -21,5 +24,16 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
void TrackCommit(EffectiveConfigEditCommittedArgs args); void TrackCommit(EffectiveConfigEditCommittedArgs args);
void RevertChange(string sectionId, string optionName); 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); Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
} }
@@ -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. /// Returns a factory that creates the correct descriptor type for this field (e.g. <see cref="EffectiveIntegerConfigFieldDescriptor"/>), or null to use the default.
/// </summary> /// </summary>
EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName); 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);
} }
@@ -7,6 +7,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
{ {
private readonly IEffectiveConfigSaveService _saveService; private readonly IEffectiveConfigSaveService _saveService;
private readonly List<PendingEffectiveConfigChange> _pending = new(); 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) public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService)
{ {
@@ -16,17 +18,37 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
public bool IsEditMode { get; private set; } public bool IsEditMode { get; private set; }
public string? ActiveFieldKey { get; private set; } public string? ActiveFieldKey { get; private set; }
public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges => _pending; 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() public void EnterEditMode()
{ {
IsEditMode = true; IsEditMode = true;
ActiveFieldKey = null; ActiveFieldKey = null;
_pending.Clear(); _pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
} }
public void ExitEditModeDiscard() public void ExitEditModeDiscard()
{ {
_pending.Clear(); _pending.Clear();
_fieldIssues.Clear();
_crossOptionIssues.Clear();
ActiveFieldKey = null; ActiveFieldKey = null;
IsEditMode = false; IsEditMode = false;
} }
@@ -42,6 +64,39 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
ActiveFieldKey = null; 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) public void TrackCommit(EffectiveConfigEditCommittedArgs args)
{ {
var existing = _pending.FirstOrDefault(c => var existing = _pending.FirstOrDefault(c =>
@@ -60,6 +115,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
c.SectionId == sectionId && c.OptionName == optionName); c.SectionId == sectionId && c.OptionName == optionName);
if (existing != null) if (existing != null)
_pending.Remove(existing); _pending.Remove(existing);
ClearFieldIssues($"{sectionId}:{optionName}");
} }
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default) public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
@@ -4,6 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays; using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
@@ -14,8 +15,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
/// </summary> /// </summary>
public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragmentRegistry 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), Type> _displayComponents = new();
private readonly Dictionary<(string SectionId, string OptionName), EffectiveConfigDescriptorFactory> _descriptorFactories = 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() public EffectiveConfigRenderFragmentRegistry()
{ {
@@ -83,6 +88,99 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
// local-service: dropdown (not set / net / host). // local-service: dropdown (not set / net / host).
RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); 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) private void RegisterComponent(string sectionId, string optionName, Type componentType)
@@ -100,7 +198,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
{ {
_displayComponents[(sectionId, optionName)] = typeof(IntegerValueDisplay); _displayComponents[(sectionId, optionName)] = typeof(IntegerValueDisplay);
_descriptorFactories[(sectionId, optionName)] = (sectionId, optionName, status, getValue, getSource, getItems) => _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 /> /// <inheritdoc />
@@ -132,4 +244,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
builder.CloseComponent(); 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;
}
} }
@@ -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();
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
}
@@ -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();
}
@@ -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.";
}
}
@@ -14,3 +14,13 @@ public delegate IEffectiveConfigFieldDescriptor EffectiveConfigDescriptorFactory
Func<DnsmasqServiceStatus?, object?>? getValue, Func<DnsmasqServiceStatus?, object?>? getValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource, Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems); 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);
@@ -13,4 +13,6 @@ public interface IEffectiveConfigFieldDescriptor
object? GetValue(); object? GetValue();
ConfigValueSource? GetSource(); ConfigValueSource? GetSource();
IReadOnlyList<ValueWithSource>? GetItems(); 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; 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> /// <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. /// 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> /// </summary>
@@ -13,10 +16,12 @@ public record EffectiveConfigFieldDescriptor(
DnsmasqServiceStatus? Status, DnsmasqServiceStatus? Status,
Func<DnsmasqServiceStatus?, object?>? ResolveValue, Func<DnsmasqServiceStatus?, object?>? ResolveValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource, Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource,
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? ResolveItems,
EffectiveConfigSingleValueValidator? ValidateValue = null
) : IEffectiveConfigFieldDescriptor ) : IEffectiveConfigFieldDescriptor
{ {
public object? GetValue() => ResolveValue?.Invoke(Status); public object? GetValue() => ResolveValue?.Invoke(Status);
public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status); public ConfigValueSource? GetSource() => ResolveSource?.Invoke(Status);
public IReadOnlyList<ValueWithSource>? GetItems() => ResolveItems?.Invoke(Status); public IReadOnlyList<ValueWithSource>? GetItems() => ResolveItems?.Invoke(Status);
public string? Validate(object? value) => ValidateValue?.Invoke(value);
} }
@@ -8,7 +8,6 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
public record EffectiveIntegerConfigFieldDescriptor( public record EffectiveIntegerConfigFieldDescriptor(
string SectionId, string SectionId,
string OptionName, string OptionName,
bool IsMultiValue,
DnsmasqServiceStatus? Status, DnsmasqServiceStatus? Status,
Func<DnsmasqServiceStatus?, object?>? ResolveValue, Func<DnsmasqServiceStatus?, object?>? ResolveValue,
Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource, Func<DnsmasqServiceStatus?, ConfigValueSource?>? ResolveSource,
@@ -18,4 +17,20 @@ public record EffectiveIntegerConfigFieldDescriptor(
int Min = 0, int Min = 0,
int Max = int.MaxValue, int Max = int.MaxValue,
int? DefaultValue = null 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}.";
});
@@ -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);