Effective config: unified save pipeline, descriptors, serialization, WriteFailed rollback

- EffectiveConfigFieldRef as value type; PendingDnsmasqChange/PendingOptionChange/PendingManagedHostsChange
- Multi-file save plan (DnsmasqSaveWritePlan, DnsmasqManagedBackup) and backup/restore in save flow
- Hosts integrated into effective-config save; Hosts.razor uses shared save modal
- IEffectiveConfigDescriptorProvider + IEffectiveConfigFieldBuilder (DI); descriptors from EffectiveConfigSections
- IEffectiveConfigPageEditor, shared descriptor resolution; addn-hosts/dhcp-host descriptor fix
- DnsmasqConfText, IEffectiveConfigDirectiveSerializer; DnsmasqConfigService uses serializer for writes
- IStructuredOptionValueHandler/Registry, DhcpHostOptionValueHandler; Dhcp.razor index-based DeleteRow fix
- SaveService: roll back partial writes on failure; SaveModal: WriteFailed shows backups and Restore button
This commit is contained in:
2026-03-12 01:48:19 +10:00
parent 3ca935e551
commit 1b3a09996f
60 changed files with 2203 additions and 579 deletions
@@ -31,7 +31,7 @@ public abstract class EffectiveConfigValueDisplayBase : ComponentBase
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
protected object? GetEffectiveDisplayValue()
{
var pending = Session?.PendingChanges.FirstOrDefault(c =>
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
return pending != null ? pending.NewValue : Descriptor.GetValue();
@@ -1,12 +1,13 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject IEffectiveConfigDescriptorProvider DescriptorProvider
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
var descriptorsBySection = Status != null ? DescriptorProvider.GetBySection(Status, views) : new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
var term = SearchTerm?.Trim() ?? "";
}
@@ -5,6 +5,7 @@
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@inject IEffectiveConfigEditSession Session
@inject IJSRuntime JSRuntime
@implements IDisposable
@if (Status != null)
{
@@ -58,6 +59,7 @@
protected override void OnInitialized()
{
Session.Changed += HandleSessionChanged;
_uiContext = new EffectiveConfigUiContext
{
RequestOptionHelpAsync = HandleOptionHelpRequested,
@@ -74,6 +76,13 @@
};
}
private void HandleSessionChanged() => _ = InvokeAsync(StateHasChanged);
public void Dispose()
{
Session.Changed -= HandleSessionChanged;
}
private async Task OnSearchTermChanged(string value)
{
_searchTerm = value ?? "";
@@ -191,7 +200,8 @@
private void RunCrossOptionEvaluator()
{
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
var optionChanges = Session.PendingChanges.OfType<PendingOptionChange>().ToList();
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, optionChanges);
Session.SetCrossOptionIssues(issues);
}
@@ -52,7 +52,7 @@ else
return;
}
var pending = Session?.PendingChanges.FirstOrDefault(c =>
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
if (pending?.OldValue is ExplicitToggleState previousState)
@@ -137,7 +137,7 @@ else
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = Descriptor.OptionName;
var pending = Session?.PendingChanges.FirstOrDefault(c =>
var pending = Session?.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
var descriptorValue = Descriptor.IsMultiValue
@@ -18,7 +18,7 @@
OnClose="@OnClose"
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
OnSave="@HandleSave"
OnRestore="@HandleRestore"
OnRestore="@HandleRestoreBackups"
OnReverted="@HandleReverted"
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
}
@@ -36,7 +36,7 @@
}
[Parameter] public bool ShowModal { get; set; }
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
@@ -107,15 +107,15 @@
await InvokeAsync(StateHasChanged);
}
private async Task HandleRestore(string backupPath)
private async Task HandleRestoreBackups(IReadOnlyList<DnsmasqManagedBackup> backups)
{
if (string.IsNullOrEmpty(backupPath)) return;
if (backups == null || backups.Count == 0) return;
_saveState = SaveUiState.RestoreInProgress;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await SaveService.RestoreAsync(backupPath);
var result = await SaveService.RestoreAsync(backups);
_restoreResult = result;
if (result.Restored && result.Restarted)
@@ -24,13 +24,19 @@
<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.
<strong>Changes were saved, but validation failed.</strong> Restart was not attempted. You can restore the backups or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
@if (SaveResult.Backups.Count > 0)
{
<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 class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
<div class="ec-save-modal-stderr mb-3">
@@ -41,11 +47,25 @@
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
{
<div class="alert alert-danger mb-0">
<strong>Save failed.</strong> @SaveResult.UserMessage
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
<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>Save failed.</strong> @SaveResult.UserMessage
@(SaveResult.Backups?.Count > 0 ? " You can restore the backups to revert any partial write, or continue editing." : "")
</div>
</div>
@if (SaveResult.Backups?.Count > 0)
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
<p class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
</div>
}
@@ -55,13 +75,19 @@
<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 the restart command failed.</strong> You can restore the backup or keep editing.
<strong>Changes were saved, but the restart command failed.</strong> You can restore the backups or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
@if (SaveResult.Backups.Count > 0)
{
<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 class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
<div class="ec-save-modal-stderr mb-3">
@@ -115,10 +141,16 @@
</div>
}
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
@if (ManagedFilePath != null)
@if (TargetFileNames.Count > 0)
{
<p class="ec-save-modal-target mb-3">
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
<p class="ec-save-modal-targets mb-3">
<span class="text-muted">Targets:</span>
<ul class="small mb-0 mt-1">
@foreach (var name in TargetFileNames)
{
<li><code class="small">@name</code></li>
}
</ul>
</p>
}
@if (PendingChanges.Count == 0)
@@ -132,80 +164,97 @@
<div class="ec-save-modal-changes list-group list-group-flush">
@foreach (var c in PendingChanges)
{
var change = c;
var oldList = AsStringList(change.OldValue);
var newList = AsStringList(change.NewValue);
var isMultiValue = oldList != null || newList != null;
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<div class="d-flex align-items-center gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
@if (!isMultiValue)
{
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue, change.OptionName)</span>
</span>
}
</div>
@if (isMultiValue)
{
var oldSeq = oldList ?? Array.Empty<string>();
var newSeq = newList ?? Array.Empty<string>();
var added = newSeq.Except(oldSeq).ToList();
var removed = oldSeq.Except(newSeq).ToList();
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@if (orderOrDuplicatesChanged)
@if (c is PendingOptionChange change)
{
var oldList = AsStringList(change.OldValue);
var newList = AsStringList(change.NewValue);
var isMultiValue = oldList != null || newList != null;
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<div class="d-flex align-items-center gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
@if (!isMultiValue)
{
<div class="text-muted">Order or duplicates changed:</div>
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
<div class="text-success">After: @string.Join(", ", newSeq)</div>
}
else
{
@foreach (var val in removed)
{
<div class="text-muted text-decoration-line-through">− @val</div>
}
@foreach (var val in added)
{
<div class="text-success">+ @val</div>
}
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue, change.OptionName)</span>
</span>
}
</div>
}
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
@if (isMultiValue)
{
var oldSeq = oldList ?? Array.Empty<string>();
var newSeq = newList ?? Array.Empty<string>();
var added = newSeq.Except(oldSeq).ToList();
var removed = oldSeq.Except(newSeq).ToList();
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@if (orderOrDuplicatesChanged)
{
<div class="text-muted">Order or duplicates changed:</div>
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
<div class="text-success">After: @string.Join(", ", newSeq)</div>
}
else
{
@foreach (var val in removed)
{
<div class="text-muted text-decoration-line-through">− @val</div>
}
@foreach (var val in added)
{
<div class="text-success">+ @val</div>
}
}
</div>
}
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => RevertOption(change)" title="Revert this change" aria-label="Revert @change.OptionName">
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
</button>
</div>
}
else if (c is PendingManagedHostsChange hostChange)
{
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<div class="d-flex align-items-center gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="Managed hosts" Class="ec-save-modal-option me-2" />
<span class="ec-save-modal-diff text-body">@hostChange.OldEntries.Count entries → @hostChange.NewEntries.Count entries</span>
</div>
<div class="small text-muted mt-1">@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="RevertManagedHosts" title="Revert managed hosts change" aria-label="Revert managed hosts">
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
</button>
</div>
}
}
</div>
}
}
</div>
<div class="modal-footer ec-save-modal-footer">
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed) && SaveResult?.Backups?.Count > 0)
{
<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="RestoreAll"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backups</button>
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
{
<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-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
{
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
{
<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-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
}
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
{
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
@@ -218,7 +267,7 @@
</div>
@code {
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
@@ -227,13 +276,26 @@
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnContinueEditing { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
[Parameter] public EventCallback<string> OnRestore { get; set; }
[Parameter] public EventCallback<IReadOnlyList<DnsmasqManagedBackup>> OnRestore { get; set; }
[Parameter] public EventCallback OnReverted { get; set; }
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
private string? ManagedFilePath => Status?.ManagedFilePath;
private IReadOnlyList<string> TargetFileNames => GetTargetFileNames();
private IReadOnlyList<string> GetTargetFileNames()
{
var list = new List<string>();
if (Status == null) return list;
var hasOptions = PendingChanges.OfType<PendingOptionChange>().Any();
if (hasOptions && !string.IsNullOrEmpty(Status.ManagedFilePath))
list.Add(System.IO.Path.GetFileName(Status.ManagedFilePath));
var hostsChange = PendingChanges.OfType<PendingManagedHostsChange>().FirstOrDefault();
if (hostsChange != null)
list.Add(System.IO.Path.GetFileName(hostsChange.ManagedHostsFilePath));
return list;
}
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
@@ -300,12 +362,18 @@
return s;
}
private async Task Revert(PendingEffectiveConfigChange change)
private async Task RevertOption(PendingOptionChange change)
{
Session.RevertChange(change.SectionId, change.OptionName);
await OnReverted.InvokeAsync();
}
private async Task RevertManagedHosts()
{
Session.RevertManagedHostsChange();
await OnReverted.InvokeAsync();
}
private async Task OnBackdropClick()
{
if (!AllowClose) return;
@@ -329,9 +397,10 @@
await OnSave.InvokeAsync();
}
private async Task Restore(string backupPath)
private async Task RestoreAll()
{
await OnRestore.InvokeAsync(backupPath);
if (SaveResult == null || SaveResult.Backups.Count == 0) return;
await OnRestore.InvokeAsync(SaveResult.Backups);
}
private async Task CloseAndEndEdit()