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,
|
FastDnsRetry: null,
|
||||||
DhcpScriptPath: null,
|
DhcpScriptPath: null,
|
||||||
MxTarget: null,
|
MxTarget: null,
|
||||||
Conntrack: null
|
Conntrack: false
|
||||||
);
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+13
-1
@@ -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; }
|
||||||
|
|||||||
+13
-2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-24
@@ -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>;
|
||||||
|
|||||||
+132
-43
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+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.
|
/// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -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)
|
||||||
|
|||||||
+134
-1
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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?, 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);
|
||||||
|
|||||||
+2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -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}.";
|
||||||
|
});
|
||||||
|
|||||||
+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