mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-21 19:32:35 +10:00
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:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user