Refactor settings modal to use ISettingsModalService and SettingsModalSections

- Replace SettingsModalContext with SettingsModalSections
- Add SettingsModalService for modal state management
- Add StatusSection.razor.css for status-dot styles
- Update layout, NavMenu, and modal components
- Update tests for config/settings changes
This commit is contained in:
2026-02-09 14:59:01 +10:00
parent d06d6ef6c9
commit b70aa02017
26 changed files with 754 additions and 317 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
</head>
<body>
<Routes />
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>
@@ -7,8 +7,9 @@
<strong>Service status</strong>
@{
var isActive = _status.DnsmasqStatus == "active";
var isUpdating = _refreshing || IsReloading;
}
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger") @(isUpdating ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(isUpdating ? "Updating…" : (isActive ? "Running" : "Not running"))" aria-hidden="true"></span>
</span>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
@@ -17,6 +17,8 @@ public partial class StatusSection : IDisposable
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public bool IsReloading { get; set; }
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
@@ -52,9 +54,13 @@ public partial class StatusSection : IDisposable
StateHasChanged();
}
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
private async Task RefreshAsync()
{
var started = DateTime.UtcNow;
_refreshing = true;
StateHasChanged();
try
{
var token = _cts.Token;
@@ -67,6 +73,13 @@ public partial class StatusSection : IDisposable
}
finally
{
var elapsed = DateTime.UtcNow - started;
var remaining = MinUpdatingDuration - elapsed;
if (remaining > TimeSpan.Zero)
{
try { await Task.Delay(remaining, _cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
_refreshing = false;
}
}
@@ -0,0 +1,13 @@
/* Status dot pulse when fetching/updating status */
@keyframes status-dot-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.status-dot.status-dot-updating {
animation: status-dot-pulse 0.7s ease-in-out infinite;
}
@@ -1,5 +1,7 @@
@inherits LayoutComponentBase
@namespace DnsmasqWebUI.Components.Layout
@inject ISettingsModalService SettingsModalService
@implements IDisposable
<div class="page">
<div class="sidebar">
@@ -14,8 +16,41 @@
</main>
</div>
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss" aria-label="Dismiss">×</span>
</div>
@code {
private bool _settingsModalOpen;
private string _settingsModalTitle = "Settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
protected override void OnInitialized()
{
SettingsModalService.OpenRequested += OnSettingsOpenRequested;
}
private void OnSettingsOpenRequested(SettingsModalContext context, string title)
{
_settingsModalContext = context;
_settingsModalTitle = title;
_settingsModalOpen = true;
StateHasChanged();
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
SettingsModalService.NotifySettingsChanged();
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
SettingsModalService.OpenRequested -= OnSettingsOpenRequested;
}
}
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Layout
@inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center">
@@ -7,6 +8,9 @@
<span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
<span>@AppTitle</span>
</a>
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
<i class="bi bi-gear-fill" aria-hidden="true"></i>
</button>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
</div>
@@ -33,4 +37,6 @@
@code {
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
}
@@ -22,6 +22,38 @@
background-color: rgba(0,0,0,0.4);
}
/* Settings button: right of brand, same visual style as nav. On mobile, leaves space for toggler. */
.nav-settings-btn {
margin-left: auto;
margin-right: 0.5rem;
padding: 0.375rem 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-settings-btn:hover {
background-color: rgba(255, 255, 255, 0.12);
color: white;
}
.nav-settings-btn .bi-gear-fill {
font-size: 1.25rem;
}
/* On mobile: leave space for toggler (right: 1rem, width 3.5rem) so settings doesn't overlap */
@media (max-width: 640.98px) {
.nav-settings-btn {
margin-right: 4rem;
}
}
.navbar-brand {
font-size: 1.1rem;
font-weight: 600;
@@ -16,33 +16,36 @@
{
<div class="mb-3">
<label class="form-label">Search settings</label>
<input type="search" class="form-control" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" />
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div>
}
@if (ShouldShowSection("ServiceStatus"))
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4">
<h6 class="text-muted">Service status</h6>
<label for="status-interval" class="form-label">Polling interval (seconds)</label>
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection("Logs"))
@if (ShouldShowSection(SettingsModalSections.Logs))
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<section class="mb-4">
<h6 class="text-muted">Recent logs</h6>
<label for="logs-interval" class="form-label">Polling interval (seconds)</label>
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection("Leases"))
@if (ShouldShowSection(SettingsModalSections.Leases))
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4">
<h6 class="text-muted">DHCP leases</h6>
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label>
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
@@ -38,6 +38,7 @@ public partial class SettingsModal : IAsyncDisposable
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
if (justOpened)
{
_searchTerm = string.Empty;
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
@@ -68,27 +69,14 @@ public partial class SettingsModal : IAsyncDisposable
}
}
private void FilterSettings() => StateHasChanged();
private bool ShouldShowSection(string key)
{
if (SettingsContext == SettingsModalContext.ServicePolling)
return key == "ServiceStatus";
if (SettingsContext == SettingsModalContext.LogsPolling)
return key == "Logs";
if (SettingsContext == SettingsModalContext.LeasesPolling)
return key == "Leases";
if (SettingsContext == SettingsModalContext.All)
{
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
var term = _searchTerm.Trim();
return key switch
{
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
_ => false
};
}
return false;
var focusedKey = SettingsModalSections.GetSectionKeyForContext(SettingsContext);
if (focusedKey != null)
return string.Equals(key, focusedKey, StringComparison.OrdinalIgnoreCase);
return SettingsModalSections.MatchesSearch(key, _searchTerm);
}
private async Task Save()
+13 -16
View File
@@ -7,6 +7,7 @@
@inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
@@ -320,7 +321,6 @@ else
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
</div>
}
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
}
@@ -336,9 +336,6 @@ else
private Timer? _leasesRefreshTimer;
private int _leasesRefreshIntervalSeconds = 5;
private string? _message;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private bool _saving;
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
@@ -404,6 +401,7 @@ else
protected override async Task OnInitializedAsync()
{
SettingsModalService.SettingsChanged += OnSettingsChanged;
await Load();
}
@@ -428,20 +426,18 @@ else
}
}
private void OpenLeasesSettings()
{
_settingsModalContext = SettingsModalContext.LeasesPolling;
_settingsModalTitle = "Leases refresh interval";
_settingsModalOpen = true;
}
private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval");
private async Task CloseSettingsModal()
private async void OnSettingsChanged()
{
_settingsModalOpen = false;
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
});
}
private void StartLeasesRefreshTimer()
@@ -640,6 +636,7 @@ else
public void Dispose()
{
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = null;
}
+20 -28
View File
@@ -5,6 +5,7 @@
@inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
@@ -77,10 +78,8 @@ else if (_status != null)
</div>
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" />
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
}
@@ -88,10 +87,6 @@ else if (_status != null)
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private ClientSettings _clientSettings = new();
private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet;
@@ -105,14 +100,15 @@ else if (_status != null)
protected override async Task OnInitializedAsync()
{
SettingsModalService.SettingsChanged += OnSettingsChanged;
_loading = true;
try
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -140,9 +136,20 @@ else if (_status != null)
}
}
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling");
private void OpenServiceStatusSettings() => SettingsModalService.Open(SettingsModalContext.ServicePolling, "Service status polling");
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
StateHasChanged();
});
}
private async Task ReloadDnsmasq()
{
@@ -193,24 +200,9 @@ else if (_status != null)
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
}
private void OpenSettingsModal(SettingsModalContext context, string title)
{
_settingsModalContext = context;
_settingsModalTitle = title;
_settingsModalOpen = true;
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
StateHasChanged();
}
public void Dispose()
{
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_cts.Cancel();
_cts.Dispose();
}
@@ -0,0 +1,23 @@
using DnsmasqWebUI.Models.Client;
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
/// </summary>
public interface ISettingsModalService
{
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
event Action<SettingsModalContext, string>? OpenRequested;
/// <summary>Raised when the modal is closed (save or cancel). Pages subscribe to reload their intervals.</summary>
event Action? SettingsChanged;
/// <summary>Opens the settings modal with the given context and title.</summary>
void Open(SettingsModalContext context, string title);
/// <summary>Called when the modal closes. Raises <see cref="SettingsChanged"/>.</summary>
void NotifySettingsChanged();
}
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Client;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <inheritdoc cref="ISettingsModalService" />
public sealed class SettingsModalService : ISettingsModalService
{
public event Action<SettingsModalContext, string>? OpenRequested;
public event Action? SettingsChanged;
public void Open(SettingsModalContext context, string title) => OpenRequested?.Invoke(context, title);
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
}
@@ -1,19 +0,0 @@
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
@@ -0,0 +1,55 @@
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
/// <summary>
/// Central definition of settings modal sections: keys, display names, field labels, and searchable text.
/// </summary>
public static class SettingsModalSections
{
public const string ServiceStatus = "ServiceStatus";
public const string Logs = "Logs";
public const string Leases = "Leases";
/// <summary>Section metadata: display name (header), field label (for input), and searchable text for filtering.</summary>
public sealed record SectionMeta(string DisplayName, string FieldLabel, string SearchableText);
public static readonly IReadOnlyDictionary<string, SectionMeta> All = new Dictionary<string, SectionMeta>(StringComparer.OrdinalIgnoreCase)
{
[ServiceStatus] = new("Service status", "Polling interval (seconds)", "service status polling interval seconds"),
[Logs] = new("Recent logs", "Polling interval (seconds)", "recent logs polling interval seconds"),
[Leases] = new("DHCP leases", "Refresh interval (seconds)", "dhcp leases refresh interval seconds"),
};
/// <summary>Maps a focused context (single-section view) to its section key. Returns null for All.</summary>
public static string? GetSectionKeyForContext(SettingsModalContext context) => context switch
{
SettingsModalContext.ServicePolling => ServiceStatus,
SettingsModalContext.LogsPolling => Logs,
SettingsModalContext.LeasesPolling => Leases,
_ => null
};
public static bool MatchesSearch(string key, string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm)) return true;
if (!All.TryGetValue(key, out var meta)) return false;
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase);
}
}
+3
View File
@@ -2,6 +2,8 @@ using DnsmasqWebUI.Components;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Helpers.Http;
using DnsmasqWebUI.Infrastructure.Services;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Options;
// When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD.
@@ -22,6 +24,7 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients();