mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 13:57:50 +10:00
Assembly-scanned services, options validator marker, UI tweaks, and config fixes
- Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces) - Add IApplicationOptionsValidator<TOptions> marker; scan only those for IValidateOptions registration - Fix ApplicationLifecycleLoggingHostedService: log RestartCommand/ReloadCommand/StatusCommand (no ApplyCommand) - NavMenu: spin animation for update-check button icon when checking; single icon with conditional nav-footer-spin class - Add NotificationHost/NotificationToast components and NotificationService for toasts - Effective config save flow, restore result, save service; various EffectiveConfig and DnsmasqOptions/validator updates - Test and script updates; docker-compose.test.yml, appsettings, DnsmasqConfKeys/tooltips/sections
This commit is contained in:
+67
-9
@@ -4,7 +4,11 @@
|
||||
@using DnsmasqWebUI.Models.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@using System.Net
|
||||
@using System.Net.Sockets
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
|
||||
@if (_customFragment is { } fragment)
|
||||
@@ -40,8 +44,14 @@
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" />
|
||||
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList
|
||||
? pendingList.Select(s => new ValueWithSource(s, null)).ToList()
|
||||
: Descriptor.GetItems();
|
||||
var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true);
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@(allReadonly ? items?.FirstOrDefault()?.Source : null)" ShowEditableBadge="@(!allReadonly)" HasPendingChange="@_hasPendingChange" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -93,14 +103,17 @@ else
|
||||
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
|
||||
_optionHelpKey = Descriptor.OptionName;
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
var descriptorValue = Descriptor.IsMultiValue
|
||||
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||
: Descriptor.GetValue();
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = Descriptor.GetValue();
|
||||
_valueAtActivation = descriptorValue;
|
||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
if (_wasActive && !IsActive)
|
||||
@@ -119,17 +132,25 @@ else
|
||||
await CommitIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task CommitIfChangedAsync()
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? _effectiveValue;
|
||||
if (!Equals(_valueAtActivation, newValue))
|
||||
if (!ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
var validationError = ValidateDraftValue(newValue);
|
||||
if (validationError != null)
|
||||
{
|
||||
NotificationService.ShowError(validationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = Descriptor.GetSource();
|
||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
}
|
||||
_draftValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
@@ -143,12 +164,19 @@ else
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = value;
|
||||
await CommitIfChangedAsync();
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
}
|
||||
|
||||
private async Task HandleConfirmEdit()
|
||||
{
|
||||
await CommitIfChangedAsync();
|
||||
if (!await CommitIfChangedAsync())
|
||||
return;
|
||||
_exitAction = ExitAction.Confirm;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
@@ -171,5 +199,35 @@ else
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private string? ValidateDraftValue(object? value)
|
||||
{
|
||||
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
|
||||
{
|
||||
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
|
||||
if (values == null) return null;
|
||||
foreach (var raw in values)
|
||||
{
|
||||
var v = raw.Trim();
|
||||
if (!IPAddress.TryParse(v, out var ip) || ip.AddressFamily != AddressFamily.InterNetwork)
|
||||
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 address.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? oldValue, object? newValue)
|
||||
{
|
||||
var oldList = AsStringList(oldValue);
|
||||
var newList = AsStringList(newValue);
|
||||
if (oldList != null || newList != null)
|
||||
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
return Equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private enum ExitAction { None, Confirm, Cancel, Revert }
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@(Source?.IsReadOnly != true && IsEditMode)" EditableAsButton="false" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="false" EditableAsButton="false" />
|
||||
</span>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
|
||||
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
@@ -27,6 +31,10 @@
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
@@ -41,6 +49,13 @@
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
|
||||
private string _editLines = "";
|
||||
|
||||
|
||||
@@ -2,13 +2,36 @@
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IEffectiveConfigSaveService SaveService
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@if (ShowModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal PendingChanges="@PendingChanges" Status="@Status" Session="@Session" OnClose="@OnClose" OnSave="@HandleSave" OnReverted="@HandleReverted" />
|
||||
<EffectiveConfigSaveModal
|
||||
PendingChanges="@PendingChanges"
|
||||
Status="@Status"
|
||||
Session="@Session"
|
||||
SaveState="@_saveState"
|
||||
SaveResult="@_saveResult"
|
||||
RestoreResult="@_restoreResult"
|
||||
OnClose="@OnClose"
|
||||
OnSave="@HandleSave"
|
||||
OnRestore="@HandleRestore"
|
||||
OnReverted="@HandleReverted" />
|
||||
}
|
||||
|
||||
@code {
|
||||
public enum SaveUiState
|
||||
{
|
||||
Review,
|
||||
Saving,
|
||||
WriteFailed,
|
||||
ReloadFailed,
|
||||
RestoreInProgress,
|
||||
RestoreFailed
|
||||
}
|
||||
|
||||
[Parameter] public bool ShowModal { get; set; }
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
@@ -16,6 +39,20 @@
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
|
||||
private SaveUiState _saveState = SaveUiState.Review;
|
||||
private EffectiveConfigSaveResult? _saveResult;
|
||||
private EffectiveConfigRestoreResult? _restoreResult;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ShowModal)
|
||||
{
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleReverted()
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
@@ -24,15 +61,60 @@
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
try
|
||||
|
||||
_saveState = SaveUiState.Saving;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await Session.ApplyAsync();
|
||||
|
||||
_saveResult = result;
|
||||
if (result.Saved && result.Reloaded)
|
||||
{
|
||||
await Session.ApplyAsync();
|
||||
var message = result.UserMessage ?? "Config saved and dnsmasq reloaded.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
if (result.Saved && !result.Reloaded)
|
||||
_saveState = SaveUiState.ReloadFailed;
|
||||
else if (!result.Saved)
|
||||
_saveState = SaveUiState.WriteFailed;
|
||||
else
|
||||
_saveState = SaveUiState.Review;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleRestore(string backupPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupPath)) return;
|
||||
|
||||
_saveState = SaveUiState.RestoreInProgress;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await SaveService.RestoreAsync(backupPath);
|
||||
|
||||
_restoreResult = result;
|
||||
if (result.Restored && result.Reloaded)
|
||||
{
|
||||
// TODO: show error to user (e.g. toast or modal message)
|
||||
var message = result.UserMessage ?? "Backup restored and dnsmasq reloaded.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.RestoreFailed;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
+160
-34
@@ -8,73 +8,187 @@
|
||||
<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"></button>
|
||||
<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">
|
||||
<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 (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<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 changes to save. Revert all above or close to keep editing.
|
||||
<div class="ec-save-modal-loading text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and reloading…" : "Restoring backup and reloading…")</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||
@foreach (var c in PendingChanges)
|
||||
<div class="alert alert-danger mb-0">
|
||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
{
|
||||
var change = c;
|
||||
<div class="list-group-item ec-save-modal-item d-flex align-items-center gap-3 py-3 px-0 border-0 border-bottom">
|
||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
||||
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
</span>
|
||||
@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="Remove this change from the list" aria-label="Revert @change.OptionName">
|
||||
Remove
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Config was saved, but reload failed.</strong> You can restore the backup or keep editing.
|
||||
</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>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
{
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-2 @(_stderrExpanded ? "active" : "")" @onclick="() => _stderrExpanded = !_stderrExpanded" aria-expanded="@_stderrExpanded">
|
||||
@(_stderrExpanded ? "Hide" : "Show") reload stderr
|
||||
</button>
|
||||
@if (_stderrExpanded)
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
@RestoreResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(RestoreResult.ReloadStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@RestoreResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isMultiValue)
|
||||
{
|
||||
var added = (newList ?? Array.Empty<string>()).Except(oldList ?? Array.Empty<string>()).ToList();
|
||||
var removed = (oldList ?? Array.Empty<string>()).Except(newList ?? Array.Empty<string>()).ToList();
|
||||
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
||||
@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">
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)">Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
}
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" style="display: block;" @onclick="Close"></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 OnSave { get; set; }
|
||||
/// <summary>Invoked after reverting a change so the parent can re-render.</summary>
|
||||
[Parameter] public EventCallback<string> OnRestore { get; set; }
|
||||
[Parameter] public EventCallback OnReverted { get; set; }
|
||||
|
||||
private bool _stderrExpanded;
|
||||
|
||||
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
||||
|
||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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)";
|
||||
}
|
||||
|
||||
@@ -84,8 +198,15 @@
|
||||
await OnReverted.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnBackdropClick()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await Close();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -93,4 +214,9 @@
|
||||
{
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Restore(string backupPath)
|
||||
{
|
||||
await OnRestore.InvokeAsync(backupPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,3 +41,21 @@
|
||||
.ec-save-modal-empty {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-loading {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-path {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ec-save-modal-pre {
|
||||
font-size: 0.8125rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user