Effective config: multi-value editor, config service fixes, save result improvements

- Add EffectiveConfigMultiValueListEditor with badge-style actions, draft merge,
  and display-mode editable badges; fix row callback capture and bounds checks
- EffectiveConfigFieldDisplay: compute multi-value state in OnParametersSet +
  HandleMultiValueChanged; BuildItemsWithSource preserves readonly/editable;
  dedupe list on commit; defensive copy for multi-value commit
- DnsmasqConfigService: generic MatchesOption via key parsing; FilterManagedOnly
  + ReadReadonlyMultiValuesByOptionAsync (single-pass readonly); EnsureManagedConfigInvariants
  + EnsureUniqueDirective; fix DHCP write cache notification (re-parse written output)
- EffectiveConfigSaveResult: ErrorCodes constants, IsValidateFailed; set Validated=false
  when validation fails; EffectiveConfigSaveFlow use IsValidateFailed; save service
  use ErrorCodes
- OptionStateBadge: OnEditableClickRequested for list-editor per-item edit
- Validation service and config save flow updates
This commit is contained in:
2026-03-02 21:10:35 +10:00
parent 8ed66cdf48
commit a3cc3968af
19 changed files with 685 additions and 80 deletions
@@ -44,13 +44,10 @@
}
else if (Descriptor.IsMultiValue)
{
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList
? pendingList.Select(s => new ValueWithSource(s, null)).ToList()
: Descriptor.GetItems();
var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true);
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@(allReadonly ? items?.FirstOrDefault()?.Source : null)" ShowEditableBadge="@(!allReadonly)" HasPendingChange="@_hasPendingChange" FieldKey="@FieldKey"
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
else
@@ -98,6 +95,15 @@ else
private bool _wasActive;
private ExitAction _exitAction = ExitAction.None;
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
private IReadOnlyList<ValueWithSource>? _multiValueItems;
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
private bool _multiValueHasDraft;
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
private ConfigValueSource? _multiValueRowSource;
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
private bool _multiValueHasPendingOrDraft;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
@@ -112,6 +118,8 @@ else
: Descriptor.GetValue();
_hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
if (!_wasActive && IsActive)
_valueAtActivation = descriptorValue;
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
@@ -121,6 +129,45 @@ else
_wasActive = IsActive;
}
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
{
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
if (_multiValueHasDraft && draftList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
}
else if (_hasPendingChange && pendingList != null)
{
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
}
else
{
_multiValueItems = descriptorItems;
}
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
}
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
{
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
var list = new List<ValueWithSource>(values.Count);
foreach (var s in values)
{
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
if (idx >= 0)
{
list.Add(pool[idx]);
pool.RemoveAt(idx);
}
else
list.Add(new ValueWithSource(s, null));
}
return list;
}
private void HandleValueChanged(object? value)
{
_draftValue = value;
@@ -145,7 +192,13 @@ else
}
var source = Descriptor.GetSource();
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
object? valueToCommit = newValue;
if (newValue is IReadOnlyList<string> list)
{
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
valueToCommit = deduped;
}
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
if (Ui != null)
await Ui.CommitFieldAsync(args);
}
@@ -171,6 +224,9 @@ else
private void HandleMultiValueChanged(IReadOnlyList<string> values)
{
_draftValue = values.ToList();
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
StateHasChanged();
}
private async Task HandleConfirmEdit()
@@ -2,9 +2,12 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
@Value
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="false" EditableAsButton="false" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="@IsEditMode"
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
</span>
@code {
@@ -13,4 +16,7 @@
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
[Parameter] public string? ReadonlyTitleOverride { get; set; }
}
@@ -0,0 +1,167 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list">
@if (SourceItems?.Count > 0)
{
@for (var i = 0; i < SourceItems.Count; i++)
{
var item = SourceItems[i];
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
<li class="ec-multi-list-row" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<input class="form-control form-control-sm ec-multi-list-input"
@bind="_draftValue" @bind:event="oninput" />
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
}
else
{
<span class="ec-field-value-item">@rowValue</span>
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
}
</li>
}
}
else
{
<li class="ec-multi-list-row ec-multi-list-empty-hint">
<span class="text-muted">(none)</span>
</li>
}
@if (AllowAddManagedValue)
{
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
@if (_addingNew)
{
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="New value"
@bind="_draftValue" @bind:event="oninput" />
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
Title="Add this value" AriaLabel="Add"
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard" AriaLabel="Cancel"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
}
else
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
Title="Add a new value" AriaLabel="Add item"
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
}
</li>
}
</ol>
</span>
@code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
[Parameter] public string OptionName { get; set; } = "";
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "add-item";
private int? _editingIndex;
private bool _addingNew;
private string _draftValue = "";
private void BeginEdit(int index, string current)
{
_addingNew = false;
_editingIndex = index;
_draftValue = current;
}
private void BeginAdd()
{
_editingIndex = null;
_addingNew = true;
_draftValue = "";
}
private void CancelInline()
{
_editingIndex = null;
_addingNew = false;
_draftValue = "";
}
private async Task SaveEdit(int index)
{
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
if (index < 0 || index >= next.Count) return;
next[index] = (_draftValue ?? "").Trim();
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task SaveNew()
{
var value = (_draftValue ?? "").Trim();
if (value.Length == 0) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
next.Add(value);
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task RemoveAt(int index)
{
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
var item = SourceItems[index];
if (item?.Source?.IsReadOnly == true) return;
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
.Select(x => x.Value)
.ToList();
if (index < 0 || index >= next.Count) return;
next.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next);
}
}
@@ -0,0 +1,65 @@
/* Editor block: distinct from display mode (border + background) */
.ec-multi-list-editor {
display: block;
padding: 0.75rem 1rem;
margin-top: 0.25rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
}
.ec-multi-list-editor .ec-field-value-list {
margin-bottom: 0;
}
.ec-multi-list-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
}
.ec-multi-list-row .ec-field-value-item {
min-width: 0;
}
.ec-multi-list-row .status-badge-inline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.ec-multi-list-row .form-control.ec-multi-list-input {
max-width: 28rem;
flex-shrink: 0;
}
.ec-multi-list-empty-hint {
list-style: none;
margin-left: -1.25rem;
}
.ec-multi-list-add-item {
list-style: none;
margin-left: -1.25rem;
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px dashed var(--bs-border-color, #dee2e6);
flex-wrap: nowrap;
}
.ec-multi-list-add-item .form-control.ec-multi-list-input {
flex: 1 1 auto;
max-width: none;
min-width: 12rem;
}
.ec-multi-list-add-item .status-badge-inline {
margin-left: auto;
}
.ec-multi-list-add-btn {
font-size: 0.875rem;
}
@@ -6,13 +6,17 @@
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
@if (IsActiveEditor)
{
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
<EffectiveConfigMultiValueListEditor
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
OptionName="@Label"
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
{
@@ -23,7 +27,7 @@
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" /></li>
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" FieldKey="@FieldKey" ReadonlyClickActivatesField="true" ReadonlyTitleOverride="New values can still be added. Click to edit." /></li>
}
</ol>
}
@@ -31,10 +35,6 @@
{
<span class="text-muted">(none)</span>
}
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
</li>
@@ -56,22 +56,4 @@
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private string _editLines = "";
protected override void OnParametersSet()
{
if (Items != null && Items.Count > 0)
_editLines = string.Join("\n", Items.Select(i => i.Value));
else
_editLines = "";
}
private async Task OnLinesInput(ChangeEventArgs e)
{
var text = e.Value?.ToString() ?? "";
_editLines = text;
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
await ItemsChanged.InvokeAsync(lines);
}
}
@@ -28,6 +28,7 @@
Review,
Saving,
WriteFailed,
ValidateFailed,
ReloadFailed,
RestoreInProgress,
RestoreFailed
@@ -83,7 +84,9 @@
return;
}
if (result.Saved && !result.Restarted)
if (result.Saved && result.IsValidateFailed)
_saveState = SaveUiState.ValidateFailed;
else if (result.Saved && !result.Restarted)
_saveState = SaveUiState.ReloadFailed;
else if (!result.Saved)
_saveState = SaveUiState.WriteFailed;
@@ -15,7 +15,28 @@
{
<div class="ec-save-modal-loading text-center py-4">
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")</p>
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")</p>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null)
{
<div class="ec-save-modal-reload-failed">
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
<div class="ec-save-modal-alert-text">
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
{
<p class="ec-save-modal-backup-path mb-2">
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
</p>
}
<div class="ec-save-modal-stderr mb-3">
<p class="text-muted small mb-1"><strong>Validation output</strong></p>
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetValidationFailedOutput(SaveResult)</pre>
</div>
</div>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
@@ -129,7 +150,7 @@
}
</div>
<div class="modal-footer ec-save-modal-footer">
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
{
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
@@ -172,6 +193,18 @@
private string? ManagedFilePath => Status?.ManagedFilePath;
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
{
var err = (result.ValidationStdErr ?? "").Trim();
var outp = (result.ValidationStdOut ?? "").Trim();
if (err.Length > 0 && outp.Length > 0)
return err + "\n\n" + outp;
if (err.Length > 0) return err;
if (outp.Length > 0) return outp;
var exitInfo = result.ValidationExitCode >= 0 ? $"Exit code: {result.ValidationExitCode}." : "";
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
}
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
{
var err = (result.RestartStdErr ?? "").Trim();
@@ -4,17 +4,25 @@
@if (Source?.IsReadOnly == true)
{
@if (IsInteractive && Ui != null)
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@(Source?.GetReadOnlyTooltip())"
Id="@AnchorId" Title="@readonlyTitle"
OnClick="OnEditableClick"
Class="status-badge-inline" />
}
else if (IsInteractive && Ui != null)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
Class="status-badge-inline" />
}
else
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
Title="@(Source?.GetReadOnlyTooltip())"
Title="@readonlyTitle"
Class="status-badge-inline" />
}
}
@@ -71,6 +79,12 @@ else if (ShowEditableBadge)
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
[Parameter] public string? FieldKey { get; set; }
/// <summary>Optional: when set, editable-badge click invokes this instead of activating the field by key (e.g. list editor per-item edit).</summary>
[Parameter] public EventCallback OnEditableClickRequested { get; set; }
/// <summary>When true, readonly badge click activates the field for editing instead of showing the readonly popover (e.g. for multi-value where new items can still be added).</summary>
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
[Parameter] public string? ReadonlyTitleOverride { get; set; }
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
private async Task OnConfirmClick()
@@ -93,10 +107,15 @@ else if (ShowEditableBadge)
await OnRevertRequested.InvokeAsync();
}
private Task OnEditableClick()
private async Task OnEditableClick()
{
if (Ui == null || string.IsNullOrEmpty(FieldKey)) return Task.CompletedTask;
return Ui.ActivateFieldAsync(FieldKey);
if (OnEditableClickRequested.HasDelegate)
{
await OnEditableClickRequested.InvokeAsync();
return;
}
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
await Ui.ActivateFieldAsync(FieldKey);
}
private Task OnReadonlyClick()
@@ -1,8 +1,10 @@
@namespace DnsmasqWebUI.Components.Layout
@using System.Reflection
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
@inject IUpdateCheckService UpdateCheckService
@inject INotificationService NotificationService
@implements IDisposable
<div class="top-row ps-3 navbar navbar-dark">
@@ -110,6 +112,12 @@
private async Task ForceCheckAsync()
{
await UpdateCheckService.CheckNowAsync();
if (UpdateCheckService.LastCheckFailed)
NotificationService.ShowError("Update check failed: " + (UpdateCheckService.LastCheckErrorMessage ?? "Unknown error."));
else if (UpdateCheckService.NewerVersionAvailable)
NotificationService.ShowSuccess($"New version {UpdateCheckService.NewerVersionTag} is available.");
else
NotificationService.ShowInfo("You're up to date.");
}
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
@@ -64,18 +64,54 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return "line:" + e.LineNumber;
}
/// <summary>When managedHostsPath is set, ensures the managed config has exactly one addn-hosts line pointing to it (replaces the first AddnHosts line or inserts at start). So dnsmasq loads our managed hosts file last.</summary>
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string? managedHostsPath)
/// <summary>
/// Applies managed-config invariants (system-owned directives) that are not user option edits.
/// This runs at write-time and is intentionally outside the pending-changes model.
/// </summary>
private static void EnsureManagedConfigInvariants(List<DnsmasqConfLine> configLines, string? managedHostsPath)
{
if (string.IsNullOrEmpty(managedHostsPath))
if (!string.IsNullOrEmpty(managedHostsPath))
{
EnsureUniqueDirective(
configLines,
DnsmasqConfKeys.AddnHosts,
managedHostsPath,
lineNumber => new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = managedHostsPath });
}
}
/// <summary>
/// Ensures exactly one directive with the given key/value exists in the managed file.
/// If multiple exist, keeps the first and removes the rest.
/// </summary>
private static void EnsureUniqueDirective(
List<DnsmasqConfLine> configLines,
string key,
string value,
Func<int, DnsmasqConfLine>? lineFactory = null)
{
var matchingIndices = new List<int>();
for (var i = 0; i < configLines.Count; i++)
{
var raw = DnsmasqConfFileLineParser.ToLine(configLines[i]);
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
if (kv != null &&
string.Equals(kv.Value.key, key, StringComparison.Ordinal) &&
string.Equals(kv.Value.value.Trim(), value, StringComparison.Ordinal))
matchingIndices.Add(i);
}
if (matchingIndices.Count == 0)
{
configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine { LineNumber = 1, RawLine = $"{key}={value}" });
return;
var idx = configLines.FindIndex(c => c.Kind == DnsmasqConfLineKind.AddnHosts);
var lineNumber = idx >= 0 ? configLines[idx].LineNumber : 1;
var line = new AddnHostsLine { LineNumber = lineNumber, AddnHostsPath = managedHostsPath };
if (idx >= 0)
configLines[idx] = line;
else
configLines.Insert(0, line);
}
var keepIdx = matchingIndices[0];
var keepLineNumber = configLines[keepIdx].LineNumber;
configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine { LineNumber = keepLineNumber, RawLine = $"{key}={value}" };
for (var i = matchingIndices.Count - 1; i >= 1; i--)
configLines.RemoveAt(matchingIndices[i]);
}
/// <summary>Creates the managed hosts file empty if it does not exist, so dnsmasq does not error when we add addn-hosts=&lt;path&gt; to the managed config.</summary>
@@ -124,7 +160,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
rawLines = Array.Empty<string>();
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
EnsureOneAddnHostsLine(configLines, set.ManagedHostsFilePath);
EnsureManagedConfigInvariants(configLines, set.ManagedHostsFilePath);
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(fileEntries);
@@ -148,8 +184,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
var writtenLines = DnsmasqConfFileLineParser.ParseFile(output).ToList();
var effectiveHostsPathDhcp = writtenLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(writtenLines, effectiveHostsPathDhcp));
_logger.LogInformation("Wrote managed config file: {Path}", path);
}
@@ -187,8 +225,9 @@ public class DnsmasqConfigService : IDnsmasqConfigService
public async Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default)
{
if (changes.Count == 0) return;
var content = await ReadManagedConfigAsync(ct);
var list = content.Lines.ToList();
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
var readonlyByOption = await ReadReadonlyMultiValuesByOptionAsync(snapshot.Set, ct);
var list = snapshot.ManagedContent.Lines.ToList();
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
foreach (var c in changes)
{
@@ -198,13 +237,15 @@ public class DnsmasqConfigService : IDnsmasqConfigService
bool MatchesOption(DnsmasqConfLine line)
{
if (line is not OtherLine o) return false;
var raw = o.RawLine.Trim();
return raw == confKey || raw.StartsWith(confKey + "=", StringComparison.Ordinal);
var raw = DnsmasqConfFileLineParser.ToLine(line);
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
return kv != null && string.Equals(kv.Value.key, confKey, StringComparison.Ordinal);
}
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
{
IReadOnlyList<string> readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty<string>();
var valuesToWrite = FilterManagedOnly(values, readonlyValues);
var matchingIndices = new List<int>();
for (var i = 0; i < list.Count; i++)
{
@@ -213,10 +254,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
for (var i = matchingIndices.Count - 1; i >= 0; i--)
list.RemoveAt(matchingIndices[i]);
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
for (var i = 0; i < values.Count; i++)
for (var i = 0; i < valuesToWrite.Count; i++)
{
var lineKey = confKey;
var lineText = string.IsNullOrEmpty(values[i]) ? lineKey : lineKey + "=" + values[i];
var lineText = string.IsNullOrEmpty(valuesToWrite[i]) ? lineKey : lineKey + "=" + valuesToWrite[i];
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
maxLineNumber++;
list.Insert(insertIdx + i, lineObj);
@@ -248,6 +289,61 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await WriteManagedConfigAsync(list, ct);
}
/// <summary>
/// Keeps only values that should be written to managed config by subtracting values provided by
/// non-managed files (multiset subtraction, order preserved).
/// </summary>
private static IReadOnlyList<string> FilterManagedOnly(
IReadOnlyList<string> requestedValues,
IReadOnlyList<string> readonlyValues)
{
if (readonlyValues.Count == 0)
return requestedValues;
var readonlyCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var value in readonlyValues)
readonlyCounts[value] = readonlyCounts.TryGetValue(value, out var count) ? count + 1 : 1;
var result = new List<string>(requestedValues.Count);
foreach (var value in requestedValues)
{
if (readonlyCounts.TryGetValue(value, out var count) && count > 0)
{
readonlyCounts[value] = count - 1;
continue;
}
result.Add(value);
}
return result;
}
/// <summary>Reads all multi-value directive values from non-managed config files, grouped by option name. Single pass per save.</summary>
private static async Task<Dictionary<string, List<string>>> ReadReadonlyMultiValuesByOptionAsync(DnsmasqConfigSet set, CancellationToken ct)
{
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var file in set.Files.Where(f => !f.IsManaged))
{
if (!File.Exists(file.Path))
continue;
var lines = await File.ReadAllLinesAsync(file.Path, DnsmasqFileEncoding.Utf8NoBom, ct);
foreach (var line in lines)
{
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
if (kv == null)
continue;
var key = kv.Value.key;
var value = kv.Value.value.Trim();
if (!result.TryGetValue(key, out var list))
{
list = new List<string>();
result[key] = list;
}
list.Add(value);
}
}
return result;
}
private static bool TryGetMultiValues(object? value, out IReadOnlyList<string> values)
{
values = null!;
@@ -291,7 +387,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
Directory.CreateDirectory(dir);
var list = lines.ToList();
EnsureOneAddnHostsLine(list, set.ManagedHostsFilePath);
EnsureManagedConfigInvariants(list, set.ManagedHostsFilePath);
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
var tmpPath = path + ".tmp";
@@ -0,0 +1,19 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
/// <summary>Runs the configured config validation command (e.g. dnsmasq --test --conf-file=...). Used before restart in the save flow.</summary>
public interface IConfigValidationService : IApplicationScopedService
{
/// <summary>Runs the validation command. When not configured, returns success without running.</summary>
Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default);
}
/// <summary>Result of running the validation command.</summary>
public record ConfigValidationResult(
bool Success,
bool Attempted,
int ExitCode,
string? StdOut,
string? StdErr,
string? UserMessage);
@@ -0,0 +1,64 @@
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation;
public sealed class ConfigValidationService : IConfigValidationService
{
private readonly DnsmasqOptions _options;
private readonly IProcessRunner _processRunner;
private readonly ILogger<ConfigValidationService> _logger;
public ConfigValidationService(
IOptions<DnsmasqOptions> options,
IProcessRunner processRunner,
ILogger<ConfigValidationService> logger)
{
_options = options.Value;
_processRunner = processRunner;
_logger = logger;
}
public async Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_options.ValidateCommand))
{
return new ConfigValidationResult(
Success: true,
Attempted: false,
ExitCode: 0,
StdOut: null,
StdErr: null,
UserMessage: "Validation command not configured.");
}
var command = _options.ValidateCommand!
.Replace("{{MainConfigPath}}", _options.MainConfigPath, StringComparison.Ordinal);
var run = await _processRunner.RunAsync(command, _options.ValidateTimeout, ct);
var stderr = run.Stderr;
if (run.TimedOut)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") +
$"Validation timed out after {_options.ValidateTimeoutSeconds} seconds.";
if (!string.IsNullOrWhiteSpace(run.ExceptionMessage))
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + run.ExceptionMessage;
var success = run.ExitCode == 0;
if (!success)
_logger.LogWarning("Config validation failed: exit {ExitCode}, stderr: {Stderr}", run.ExitCode, stderr);
return new ConfigValidationResult(
Success: success,
Attempted: true,
ExitCode: run.ExitCode ?? -1,
StdOut: run.Stdout,
StdErr: stderr,
UserMessage: success
? "Configuration validated."
: "Configuration validation failed.");
}
}
@@ -1,5 +1,6 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging;
@@ -10,17 +11,20 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IConfigValidationService _validationService;
private readonly IReloadService _reloadService;
private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService(
IDnsmasqConfigSetService configSetService,
IDnsmasqConfigService configService,
IConfigValidationService validationService,
IReloadService reloadService,
ILogger<EffectiveConfigSaveService> logger)
{
_configSetService = configSetService;
_configService = configService;
_validationService = validationService;
_reloadService = reloadService;
_logger = logger;
}
@@ -38,8 +42,8 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{
_logger.LogWarning("Save skipped: managed config path is not configured");
return new EffectiveConfigSaveResult(
false, null, false, false, -1, null, null,
"missing_managed_path", "Managed config path is not configured.");
false, null, false, false, -1, null, null, false, -1, null, null,
EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured.");
}
var managedPath = set.ManagedFilePath!;
@@ -61,13 +65,38 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
false,
-1,
null,
null,
false,
-1,
null,
ex.Message,
"write_failed",
EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
"Failed to write config.");
}
var restartResult = await _reloadService.ReloadAsync(ct);
var backupCreated = File.Exists(backupPath);
var validateResult = await _validationService.ValidateAsync(ct);
if (!validateResult.Success)
{
_logger.LogWarning("Config saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr);
return new EffectiveConfigSaveResult(
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Validated: false,
ValidationExitCode: validateResult.ExitCode,
ValidationStdOut: validateResult.StdOut,
ValidationStdErr: validateResult.StdErr,
Restarted: false,
RestartExitCode: -1,
RestartStdOut: null,
RestartStdErr: null,
ErrorCode: EffectiveConfigSaveResult.ErrorCodes.ValidateFailed,
UserMessage: "Saved, but validation failed. Restart was not attempted.");
}
var restartResult = await _reloadService.ReloadAsync(ct);
if (!restartResult.Success)
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr);
@@ -76,12 +105,18 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null,
Saved: true,
Validated: true,
ValidationExitCode: 0,
ValidationStdOut: validateResult.StdOut,
ValidationStdErr: validateResult.StdErr,
Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode,
RestartStdOut: restartResult.StdOut,
RestartStdErr: restartResult.StdErr,
ErrorCode: restartResult.Success ? null : "restart_failed",
UserMessage: restartResult.Success ? "Saved and dnsmasq restarted." : "Saved, but the restart command failed.");
ErrorCode: restartResult.Success ? null : EffectiveConfigSaveResult.ErrorCodes.RestartFailed,
UserMessage: restartResult.Success
? "Saved, validated, and dnsmasq restarted."
: "Saved and validated, but the restart command failed.");
}
/// <inheritdoc />
@@ -24,6 +24,12 @@ public interface IUpdateCheckService : IApplicationSingleton
/// <summary>When the last check completed (success or failure), or null if never run.</summary>
DateTime? LastCheckTime { get; }
/// <summary>True if the last check threw an error (e.g. network failure).</summary>
bool LastCheckFailed { get; }
/// <summary>Error message from the last failed check, or null if the last check succeeded.</summary>
string? LastCheckErrorMessage { get; }
/// <summary>True while a check is in progress.</summary>
bool CheckInProgress { get; }
@@ -21,6 +21,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
private string? _newerVersionUrl;
private DateTime? _lastCheckTime;
private bool _checkInProgress;
private bool _lastCheckFailed;
private string? _lastCheckErrorMessage;
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
{
@@ -34,6 +36,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
public string? NewerVersionTag => _newerVersionTag;
public string? NewerVersionUrl => _newerVersionUrl;
public DateTime? LastCheckTime => _lastCheckTime;
public bool LastCheckFailed => _lastCheckFailed;
public string? LastCheckErrorMessage => _lastCheckErrorMessage;
public bool CheckInProgress => _checkInProgress;
public async Task CheckNowAsync()
@@ -46,6 +50,12 @@ public sealed class UpdateCheckService : IUpdateCheckService
try
{
lock (_lock)
{
_lastCheckFailed = false;
_lastCheckErrorMessage = null;
}
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
string? newTag = null;
@@ -79,12 +89,14 @@ public sealed class UpdateCheckService : IUpdateCheckService
ResultChanged?.Invoke(this, EventArgs.Empty);
}
catch
catch (Exception ex)
{
lock (_lock)
{
_lastCheckTime = DateTime.UtcNow;
_checkInProgress = false;
_lastCheckFailed = true;
_lastCheckErrorMessage = ex.Message;
}
ResultChanged?.Invoke(this, EventArgs.Empty);
}
@@ -35,6 +35,9 @@ public class DnsmasqOptions
/// <summary>Command to run after config changes when <see cref="RestartCommand"/> is not set (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). SIGHUP only re-reads hosts/addn-hosts etc., not .conf files. Runs in the same environment as the app.</summary>
public string? ReloadCommand { get; set; }
/// <summary>Optional command to validate config before restart (e.g. "dnsmasq --test --conf-file=\"{{MainConfigPath}}\""). Token {{MainConfigPath}} is replaced with <see cref="MainConfigPath"/>. When set, run after write and before restart; if it fails, restart is not attempted. When unset, validation is skipped.</summary>
public string? ValidateCommand { get; set; } = "dnsmasq --test --conf-file=\"{{MainConfigPath}}\"";
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
public string? StatusCommand { get; set; }
@@ -58,6 +61,9 @@ public class DnsmasqOptions
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
public int LogsTimeoutSeconds { get; set; } = 10;
/// <summary>Timeout in seconds for <see cref="ValidateCommand"/>. Default 10.</summary>
public int ValidateTimeoutSeconds { get; set; } = 10;
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
@@ -69,4 +75,7 @@ public class DnsmasqOptions
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds);
/// <summary><see cref="ValidateTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan ValidateTimeout => TimeSpan.FromSeconds(ValidateTimeoutSeconds);
}
@@ -44,6 +44,8 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}.");
if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}.");
if (options.ValidateTimeoutSeconds < minTimeoutSeconds || options.ValidateTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:ValidateTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.ValidateTimeoutSeconds}.");
if (failures.Count == 0)
return ValidateOptionsResult.Success;
@@ -1,19 +1,27 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>Result of the effective-config save flow (backup, write, restart). Used by UI to drive state and show restore/continue options.</summary>
/// <summary>Result of the effective-config save flow (backup, write, validate, restart). Used by UI to drive state and show restore/continue options.</summary>
/// <param name="BackupCreated">True when a backup file was created before write.</param>
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
/// <param name="Saved">True when the managed config was written successfully.</param>
/// <param name="Restarted">True when the restart command succeeded after write (config changes require restart, not SIGHUP).</param>
/// <param name="Validated">True when validation was run and succeeded (or was skipped). When validation ran and failed, restart is not attempted.</param>
/// <param name="ValidationExitCode">Validation command exit code; -1 when not run or failed to start.</param>
/// <param name="ValidationStdOut">Standard output from the validation command.</param>
/// <param name="ValidationStdErr">Standard error from the validation command.</param>
/// <param name="Restarted">True when the restart command succeeded after write (and after validation if run).</param>
/// <param name="RestartExitCode">Restart command process exit code; -1 when not run or failed to start.</param>
/// <param name="RestartStdOut">Standard output from the restart command.</param>
/// <param name="RestartStdErr">Standard error from the restart command.</param>
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, restart_failed, or null when success.</param>
/// <param name="ErrorCode">Machine-readable code: see <see cref="ErrorCodes"/>; null when success.</param>
/// <param name="UserMessage">Short message for the user.</param>
public record EffectiveConfigSaveResult(
bool BackupCreated,
string? BackupPath,
bool Saved,
bool Validated,
int ValidationExitCode,
string? ValidationStdOut,
string? ValidationStdErr,
bool Restarted,
int RestartExitCode,
string? RestartStdOut,
@@ -21,7 +29,20 @@ public record EffectiveConfigSaveResult(
string? ErrorCode,
string? UserMessage)
{
/// <summary>Machine-readable error codes for <see cref="ErrorCode"/>.</summary>
public static class ErrorCodes
{
public const string NoChanges = "no_changes";
public const string MissingManagedPath = "missing_managed_path";
public const string WriteFailed = "write_failed";
public const string ValidateFailed = "validate_failed";
public const string RestartFailed = "restart_failed";
}
/// <summary>True when config was written but validation failed (restart not attempted).</summary>
public bool IsValidateFailed => ErrorCode == ErrorCodes.ValidateFailed;
/// <summary>Result when there are no pending changes to apply.</summary>
public static EffectiveConfigSaveResult NoChanges() =>
new(false, null, false, false, -1, null, null, "no_changes", "No pending changes.");
new(false, null, false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes.");
}
+2
View File
@@ -46,6 +46,8 @@
"SystemHostsPath": "/etc/hosts",
"RestartCommand": "systemctl restart dnsmasq",
"ReloadCommand": "systemctl reload dnsmasq",
"ValidateCommand": "dnsmasq --test --conf-file=\"{{MainConfigPath}}\"",
"ValidateTimeoutSeconds": 10,
"StatusCommand": "systemctl is-active dnsmasq",
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"