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) else if (Descriptor.IsMultiValue)
{ {
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList @* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
? pendingList.Select(s => new ValueWithSource(s, null)).ToList() <EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
: 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"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" 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" /> ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
} }
else else
@@ -98,6 +95,15 @@ else
private bool _wasActive; private bool _wasActive;
private ExitAction _exitAction = ExitAction.None; 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() protected override void OnParametersSet()
{ {
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName); _customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
@@ -112,6 +118,8 @@ else
: Descriptor.GetValue(); : Descriptor.GetValue();
_hasPendingChange = pending != null; _hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : descriptorValue; _effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
if (!_wasActive && IsActive) if (!_wasActive && IsActive)
_valueAtActivation = descriptorValue; _valueAtActivation = descriptorValue;
if (_wasActive && !IsActive && _exitAction == ExitAction.None) if (_wasActive && !IsActive && _exitAction == ExitAction.None)
@@ -121,6 +129,45 @@ else
_wasActive = IsActive; _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) private void HandleValueChanged(object? value)
{ {
_draftValue = value; _draftValue = value;
@@ -145,7 +192,13 @@ else
} }
var source = Descriptor.GetSource(); 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) if (Ui != null)
await Ui.CommitFieldAsync(args); await Ui.CommitFieldAsync(args);
} }
@@ -171,6 +224,9 @@ else
private void HandleMultiValueChanged(IReadOnlyList<string> values) private void HandleMultiValueChanged(IReadOnlyList<string> values)
{ {
_draftValue = values.ToList(); _draftValue = values.ToList();
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
StateHasChanged();
} }
private async Task HandleConfirmEdit() private async Task HandleConfirmEdit()
@@ -2,9 +2,12 @@
@using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @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 @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> </span>
@code { @code {
@@ -13,4 +16,7 @@
[Parameter] public string OptionName { get; set; } = ""; [Parameter] public string OptionName { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; } [Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorId { 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> <OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
@if (IsActiveEditor) @if (IsActiveEditor)
{ {
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true"> <EffectiveConfigMultiValueListEditor
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea> SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items" OptionName="@Label"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor" IsEditMode="@IsEditMode"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" /> AllowAddManagedValue="@IsEditMode"
</span> 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 else
{ {
@@ -23,7 +27,7 @@
@foreach (var (item, index) in Items!.Select((item, index) => (item, index))) @foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
{ {
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + 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> </ol>
} }
@@ -31,10 +35,6 @@
{ {
<span class="text-muted">(none)</span> <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> </span>
} }
</li> </li>
@@ -56,22 +56,4 @@
[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 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, Review,
Saving, Saving,
WriteFailed, WriteFailed,
ValidateFailed,
ReloadFailed, ReloadFailed,
RestoreInProgress, RestoreInProgress,
RestoreFailed RestoreFailed
@@ -83,7 +84,9 @@
return; return;
} }
if (result.Saved && !result.Restarted) if (result.Saved && result.IsValidateFailed)
_saveState = SaveUiState.ValidateFailed;
else if (result.Saved && !result.Restarted)
_saveState = SaveUiState.ReloadFailed; _saveState = SaveUiState.ReloadFailed;
else if (!result.Saved) else if (!result.Saved)
_saveState = SaveUiState.WriteFailed; _saveState = SaveUiState.WriteFailed;
@@ -15,7 +15,28 @@
{ {
<div class="ec-save-modal-loading text-center py-4"> <div class="ec-save-modal-loading text-center py-4">
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div> <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> </div>
} }
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null) else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
@@ -129,7 +150,7 @@
} }
</div> </div>
<div class="modal-footer ec-save-modal-footer"> <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-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> <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 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) private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
{ {
var err = (result.RestartStdErr ?? "").Trim(); var err = (result.RestartStdErr ?? "").Trim();
@@ -4,17 +4,25 @@
@if (Source?.IsReadOnly == true) @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" <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" OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
Class="status-badge-inline" /> Class="status-badge-inline" />
} }
else else
{ {
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" <StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
Title="@(Source?.GetReadOnlyTooltip())" Title="@readonlyTitle"
Class="status-badge-inline" /> Class="status-badge-inline" />
} }
} }
@@ -71,6 +79,12 @@ else if (ShowEditableBadge)
[Parameter] public EventCallback OnCancelRequested { get; set; } [Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; } [Parameter] public EventCallback OnRevertRequested { get; set; }
[Parameter] public string? FieldKey { 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; } [CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
private async Task OnConfirmClick() private async Task OnConfirmClick()
@@ -93,10 +107,15 @@ else if (ShowEditableBadge)
await OnRevertRequested.InvokeAsync(); await OnRevertRequested.InvokeAsync();
} }
private Task OnEditableClick() private async Task OnEditableClick()
{ {
if (Ui == null || string.IsNullOrEmpty(FieldKey)) return Task.CompletedTask; if (OnEditableClickRequested.HasDelegate)
return Ui.ActivateFieldAsync(FieldKey); {
await OnEditableClickRequested.InvokeAsync();
return;
}
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
await Ui.ActivateFieldAsync(FieldKey);
} }
private Task OnReadonlyClick() private Task OnReadonlyClick()
@@ -1,8 +1,10 @@
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
@using System.Reflection @using System.Reflection
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService @inject ISettingsModalService SettingsModalService
@inject IUpdateCheckService UpdateCheckService @inject IUpdateCheckService UpdateCheckService
@inject INotificationService NotificationService
@implements IDisposable @implements IDisposable
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
@@ -110,6 +112,12 @@
private async Task ForceCheckAsync() private async Task ForceCheckAsync()
{ {
await UpdateCheckService.CheckNowAsync(); 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"); private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
@@ -64,18 +64,54 @@ public class DnsmasqConfigService : IDnsmasqConfigService
return "line:" + e.LineNumber; 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> /// <summary>
private static void EnsureOneAddnHostsLine(List<DnsmasqConfLine> configLines, string? managedHostsPath) /// 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; 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 }; var keepIdx = matchingIndices[0];
if (idx >= 0) var keepLineNumber = configLines[keepIdx].LineNumber;
configLines[idx] = line; configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine { LineNumber = keepLineNumber, RawLine = $"{key}={value}" };
else for (var i = matchingIndices.Count - 1; i >= 1; i--)
configLines.Insert(0, line); 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> /// <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>(); rawLines = Array.Empty<string>();
var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList(); var configLines = DnsmasqConfFileLineParser.ParseFile(rawLines).ToList();
EnsureOneAddnHostsLine(configLines, set.ManagedHostsFilePath); EnsureManagedConfigInvariants(configLines, set.ManagedHostsFilePath);
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList(); var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
AssignStableIds(fileEntries); AssignStableIds(fileEntries);
@@ -148,8 +184,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct); await File.WriteAllLinesAsync(tmpPath, output, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
EnsureManagedHostsFileExists(set.ManagedHostsFilePath); 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); _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) public async Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default)
{ {
if (changes.Count == 0) return; if (changes.Count == 0) return;
var content = await ReadManagedConfigAsync(ct); var snapshot = await _configSetCache.GetSnapshotAsync(ct);
var list = content.Lines.ToList(); var readonlyByOption = await ReadReadonlyMultiValuesByOptionAsync(snapshot.Set, ct);
var list = snapshot.ManagedContent.Lines.ToList();
var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0; var maxLineNumber = list.Count > 0 ? list.Max(l => l.LineNumber) : 0;
foreach (var c in changes) foreach (var c in changes)
{ {
@@ -198,13 +237,15 @@ public class DnsmasqConfigService : IDnsmasqConfigService
bool MatchesOption(DnsmasqConfLine line) bool MatchesOption(DnsmasqConfLine line)
{ {
if (line is not OtherLine o) return false; var raw = DnsmasqConfFileLineParser.ToLine(line);
var raw = o.RawLine.Trim(); var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(raw);
return raw == confKey || raw.StartsWith(confKey + "=", StringComparison.Ordinal); return kv != null && string.Equals(kv.Value.key, confKey, StringComparison.Ordinal);
} }
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values)) 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>(); var matchingIndices = new List<int>();
for (var i = 0; i < list.Count; i++) 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--) for (var i = matchingIndices.Count - 1; i >= 0; i--)
list.RemoveAt(matchingIndices[i]); list.RemoveAt(matchingIndices[i]);
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count; 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 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 }; var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
maxLineNumber++; maxLineNumber++;
list.Insert(insertIdx + i, lineObj); list.Insert(insertIdx + i, lineObj);
@@ -248,6 +289,61 @@ public class DnsmasqConfigService : IDnsmasqConfigService
await WriteManagedConfigAsync(list, ct); 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) private static bool TryGetMultiValues(object? value, out IReadOnlyList<string> values)
{ {
values = null!; values = null!;
@@ -291,7 +387,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var list = lines.ToList(); var list = lines.ToList();
EnsureOneAddnHostsLine(list, set.ManagedHostsFilePath); EnsureManagedConfigInvariants(list, set.ManagedHostsFilePath);
var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList(); var output = list.Select(DnsmasqConfFileLineParser.ToLine).ToList();
var tmpPath = path + ".tmp"; 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.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,17 +11,20 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{ {
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService; private readonly IDnsmasqConfigService _configService;
private readonly IConfigValidationService _validationService;
private readonly IReloadService _reloadService; private readonly IReloadService _reloadService;
private readonly ILogger<EffectiveConfigSaveService> _logger; private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService( public EffectiveConfigSaveService(
IDnsmasqConfigSetService configSetService, IDnsmasqConfigSetService configSetService,
IDnsmasqConfigService configService, IDnsmasqConfigService configService,
IConfigValidationService validationService,
IReloadService reloadService, IReloadService reloadService,
ILogger<EffectiveConfigSaveService> logger) ILogger<EffectiveConfigSaveService> logger)
{ {
_configSetService = configSetService; _configSetService = configSetService;
_configService = configService; _configService = configService;
_validationService = validationService;
_reloadService = reloadService; _reloadService = reloadService;
_logger = logger; _logger = logger;
} }
@@ -38,8 +42,8 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{ {
_logger.LogWarning("Save skipped: managed config path is not configured"); _logger.LogWarning("Save skipped: managed config path is not configured");
return new EffectiveConfigSaveResult( return new EffectiveConfigSaveResult(
false, null, false, false, -1, null, null, false, null, false, false, -1, null, null, false, -1, null, null,
"missing_managed_path", "Managed config path is not configured."); EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured.");
} }
var managedPath = set.ManagedFilePath!; var managedPath = set.ManagedFilePath!;
@@ -61,13 +65,38 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
false, false,
-1, -1,
null, null,
null,
false,
-1,
null,
ex.Message, ex.Message,
"write_failed", EffectiveConfigSaveResult.ErrorCodes.WriteFailed,
"Failed to write config."); "Failed to write config.");
} }
var restartResult = await _reloadService.ReloadAsync(ct);
var backupCreated = File.Exists(backupPath); 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) if (!restartResult.Success)
_logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr); _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, BackupCreated: backupCreated,
BackupPath: backupCreated ? backupPath : null, BackupPath: backupCreated ? backupPath : null,
Saved: true, Saved: true,
Validated: true,
ValidationExitCode: 0,
ValidationStdOut: validateResult.StdOut,
ValidationStdErr: validateResult.StdErr,
Restarted: restartResult.Success, Restarted: restartResult.Success,
RestartExitCode: restartResult.ExitCode, RestartExitCode: restartResult.ExitCode,
RestartStdOut: restartResult.StdOut, RestartStdOut: restartResult.StdOut,
RestartStdErr: restartResult.StdErr, RestartStdErr: restartResult.StdErr,
ErrorCode: restartResult.Success ? null : "restart_failed", ErrorCode: restartResult.Success ? null : EffectiveConfigSaveResult.ErrorCodes.RestartFailed,
UserMessage: restartResult.Success ? "Saved and dnsmasq restarted." : "Saved, but the restart command failed."); UserMessage: restartResult.Success
? "Saved, validated, and dnsmasq restarted."
: "Saved and validated, but the restart command failed.");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -24,6 +24,12 @@ public interface IUpdateCheckService : IApplicationSingleton
/// <summary>When the last check completed (success or failure), or null if never run.</summary> /// <summary>When the last check completed (success or failure), or null if never run.</summary>
DateTime? LastCheckTime { get; } 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> /// <summary>True while a check is in progress.</summary>
bool CheckInProgress { get; } bool CheckInProgress { get; }
@@ -21,6 +21,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
private string? _newerVersionUrl; private string? _newerVersionUrl;
private DateTime? _lastCheckTime; private DateTime? _lastCheckTime;
private bool _checkInProgress; private bool _checkInProgress;
private bool _lastCheckFailed;
private string? _lastCheckErrorMessage;
public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options) public UpdateCheckService(IHttpClientFactory httpClientFactory, IOptions<UpdateCheckOptions> options)
{ {
@@ -34,6 +36,8 @@ public sealed class UpdateCheckService : IUpdateCheckService
public string? NewerVersionTag => _newerVersionTag; public string? NewerVersionTag => _newerVersionTag;
public string? NewerVersionUrl => _newerVersionUrl; public string? NewerVersionUrl => _newerVersionUrl;
public DateTime? LastCheckTime => _lastCheckTime; public DateTime? LastCheckTime => _lastCheckTime;
public bool LastCheckFailed => _lastCheckFailed;
public string? LastCheckErrorMessage => _lastCheckErrorMessage;
public bool CheckInProgress => _checkInProgress; public bool CheckInProgress => _checkInProgress;
public async Task CheckNowAsync() public async Task CheckNowAsync()
@@ -46,6 +50,12 @@ public sealed class UpdateCheckService : IUpdateCheckService
try try
{ {
lock (_lock)
{
_lastCheckFailed = false;
_lastCheckErrorMessage = null;
}
var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName); var client = _httpClientFactory.CreateClient(ServiceCollectionExtensions.GitHubClientName);
var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest"); var response = await client.GetAsync("repos/alexhopeoconnor/dnsmasq-webui/releases/latest");
string? newTag = null; string? newTag = null;
@@ -79,12 +89,14 @@ public sealed class UpdateCheckService : IUpdateCheckService
ResultChanged?.Invoke(this, EventArgs.Empty); ResultChanged?.Invoke(this, EventArgs.Empty);
} }
catch catch (Exception ex)
{ {
lock (_lock) lock (_lock)
{ {
_lastCheckTime = DateTime.UtcNow; _lastCheckTime = DateTime.UtcNow;
_checkInProgress = false; _checkInProgress = false;
_lastCheckFailed = true;
_lastCheckErrorMessage = ex.Message;
} }
ResultChanged?.Invoke(this, EventArgs.Empty); 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> /// <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; } 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> /// <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; } public string? StatusCommand { get; set; }
@@ -58,6 +61,9 @@ public class DnsmasqOptions
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary> /// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
public int LogsTimeoutSeconds { get; set; } = 10; 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> /// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds); public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
@@ -69,4 +75,7 @@ public class DnsmasqOptions
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary> /// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds); 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}."); failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}.");
if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds) if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds)
failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}."); 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) if (failures.Count == 0)
return ValidateOptionsResult.Success; return ValidateOptionsResult.Success;
@@ -1,19 +1,27 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; 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="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="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="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="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="RestartStdOut">Standard output from the restart command.</param>
/// <param name="RestartStdErr">Standard error 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> /// <param name="UserMessage">Short message for the user.</param>
public record EffectiveConfigSaveResult( public record EffectiveConfigSaveResult(
bool BackupCreated, bool BackupCreated,
string? BackupPath, string? BackupPath,
bool Saved, bool Saved,
bool Validated,
int ValidationExitCode,
string? ValidationStdOut,
string? ValidationStdErr,
bool Restarted, bool Restarted,
int RestartExitCode, int RestartExitCode,
string? RestartStdOut, string? RestartStdOut,
@@ -21,7 +29,20 @@ public record EffectiveConfigSaveResult(
string? ErrorCode, string? ErrorCode,
string? UserMessage) 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> /// <summary>Result when there are no pending changes to apply.</summary>
public static EffectiveConfigSaveResult NoChanges() => 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", "SystemHostsPath": "/etc/hosts",
"RestartCommand": "systemctl restart dnsmasq", "RestartCommand": "systemctl restart dnsmasq",
"ReloadCommand": "systemctl reload dnsmasq", "ReloadCommand": "systemctl reload dnsmasq",
"ValidateCommand": "dnsmasq --test --conf-file=\"{{MainConfigPath}}\"",
"ValidateTimeoutSeconds": 10,
"StatusCommand": "systemctl is-active dnsmasq", "StatusCommand": "systemctl is-active dnsmasq",
"StatusShowCommand": "systemctl status dnsmasq --no-pager", "StatusShowCommand": "systemctl status dnsmasq --no-pager",
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager" "LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"