mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-14 15:41:30 +10:00
Infrastructure: - Parsers under Serialization/Parsers: Dnsmasq, DnsmasqConfig, EffectiveConfig - EffectiveConfig: Abstractions, Metadata (keys, views, field builder, tooltips, placeholders, write semantics), Rendering, Editing, Validation/Handlers, Readonly - Helpers/Config: only DnsmasqFileEncoding; DnsmasqOptionTooltips moved to Metadata - Remove empty Infrastructure/Parsers and Helpers/Dnsmasq Tests: - Mirror main project: Serialization/Parsers (Dnsmasq, DnsmasqConfig), Services/Dnsmasq/Config, Services/EffectiveConfig, Models, Helpers (TestDataHelper) - Namespaces updated; usings for TestDataHelper and Metadata where needed Docs: README codebase overview and test layout updated.
343 lines
20 KiB
Plaintext
343 lines
20 KiB
Plaintext
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||
@using DnsmasqWebUI.Models.Dnsmasq
|
||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||
|
||
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="ec-save-modal-title" aria-modal="true">
|
||
<div class="modal-dialog modal-dialog-scrollable" role="document">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
|
||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
|
||
</div>
|
||
<div class="modal-body ec-save-modal-body">
|
||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||
{
|
||
<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, 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)
|
||
{
|
||
<div class="alert alert-danger mb-0">
|
||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
|
||
{
|
||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
|
||
}
|
||
</div>
|
||
}
|
||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && 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 the restart command failed.</strong> 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>Command output</strong></p>
|
||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
|
||
</div>
|
||
</div>
|
||
}
|
||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||
{
|
||
<div class="ec-save-modal-restore-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">@RestoreResult.UserMessage</div>
|
||
</div>
|
||
<div class="ec-save-modal-stderr mb-3">
|
||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
|
||
</div>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
|
||
{
|
||
<div class="ec-save-modal-validation mb-3">
|
||
@if (ValidationErrors.Count > 0)
|
||
{
|
||
<div class="alert alert-danger py-2 mb-2">
|
||
<strong>Validation errors</strong>
|
||
<ul class="mb-0 mt-1 ps-3">
|
||
@foreach (var issue in ValidationErrors)
|
||
{
|
||
<li>@issue.Message</li>
|
||
}
|
||
</ul>
|
||
</div>
|
||
}
|
||
@if (ValidationWarnings.Count > 0)
|
||
{
|
||
<div class="alert alert-warning py-2 mb-2">
|
||
<strong>Validation warnings</strong>
|
||
<ul class="mb-0 mt-1 ps-3">
|
||
@foreach (var issue in ValidationWarnings)
|
||
{
|
||
<li>@issue.Message</li>
|
||
}
|
||
</ul>
|
||
</div>
|
||
}
|
||
</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)
|
||
{
|
||
<p class="ec-save-modal-target mb-3">
|
||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
||
</p>
|
||
}
|
||
@if (PendingChanges.Count == 0)
|
||
{
|
||
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
|
||
No pending changes. Close to continue editing.
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<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)
|
||
{
|
||
<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="() => 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>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
<div class="modal-footer ec-save-modal-footer">
|
||
@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="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.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>
|
||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0 || HasBlockingValidationErrors)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
|
||
</div>
|
||
|
||
@code {
|
||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
||
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
|
||
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
|
||
[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 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<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
|
||
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
|
||
private bool HasBlockingValidationErrors => Session.HasBlockingValidationErrors();
|
||
|
||
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();
|
||
var outp = (result.RestartStdOut ?? "").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.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||
}
|
||
|
||
private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result)
|
||
{
|
||
var err = (result.RestartStdErr ?? "").Trim();
|
||
if (err.Length > 0) return err;
|
||
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||
}
|
||
|
||
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
|
||
private static IReadOnlyList<string>? AsStringList(object? v)
|
||
{
|
||
if (v == null) return null;
|
||
if (v is IReadOnlyList<string> ro) return ro;
|
||
if (v is List<string> list) return list;
|
||
if (v is string[] arr) return arr;
|
||
return null;
|
||
}
|
||
|
||
private static string FormatValue(object? v, string? optionName = null)
|
||
{
|
||
if (optionName != null && EffectiveConfigWriteSemantics.GetBehavior(optionName) == EffectiveConfigWriteBehavior.KeyOnlyOrValue)
|
||
return FormatKeyOnlyOrValue(v);
|
||
if (v == null) return "(not set)";
|
||
if (v is bool b) return b ? "on" : "off";
|
||
var list = AsStringList(v);
|
||
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
|
||
return v.ToString() ?? "(not set)";
|
||
}
|
||
|
||
private static string FormatKeyOnlyOrValue(object? v)
|
||
{
|
||
if (v == null) return "(not set)";
|
||
var s = v.ToString();
|
||
if (string.IsNullOrEmpty(s)) return "(set)";
|
||
return s;
|
||
}
|
||
|
||
private async Task Revert(PendingEffectiveConfigChange change)
|
||
{
|
||
Session.RevertChange(change.SectionId, change.OptionName);
|
||
await OnReverted.InvokeAsync();
|
||
}
|
||
|
||
private async Task OnBackdropClick()
|
||
{
|
||
if (!AllowClose) return;
|
||
await Close();
|
||
}
|
||
|
||
private async Task Close()
|
||
{
|
||
if (!AllowClose) return;
|
||
await OnClose.InvokeAsync();
|
||
}
|
||
|
||
private async Task ContinueEditing()
|
||
{
|
||
if (!AllowClose) return;
|
||
await OnContinueEditing.InvokeAsync();
|
||
}
|
||
|
||
private async Task Save()
|
||
{
|
||
await OnSave.InvokeAsync();
|
||
}
|
||
|
||
private async Task Restore(string backupPath)
|
||
{
|
||
await OnRestore.InvokeAsync(backupPath);
|
||
}
|
||
|
||
private async Task CloseAndEndEdit()
|
||
{
|
||
if (!AllowClose) return;
|
||
await OnCloseAndEndEdit.InvokeAsync();
|
||
}
|
||
}
|