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:
2026-03-01 19:21:09 +10:00
parent b0ce7afb58
commit a776f445b6
52 changed files with 1016 additions and 155 deletions
@@ -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 }
}
@@ -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);
}
}
@@ -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;
}