Refactor: Bootstrap 5.3.8, Bootstrap Icons, Client/Http/Configuration layout

- Update Bootstrap to 5.3.8, add Bootstrap Icons; replace custom SVGs
- Fix settings modal: load editing state only when modal opens (avoid refresh overwrite)
- Home: StatusSection and LogsSection components with own timers
- Move HTTP clients under Client/Http with Abstractions namespace pattern
- Rename Options to Configuration; remove empty Options dir
- Components: Modals/, Home/; consistent Abstractions (Services, Client.Services, Client.Http)
This commit is contained in:
2026-02-01 22:51:05 +10:00
parent e50cfc986c
commit 92dfa2b170
95 changed files with 1246 additions and 636 deletions
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["lib/bootstrap-icons/bootstrap-icons.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap />
@@ -0,0 +1,37 @@
@namespace DnsmasqWebUI.Components.Home
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Recent logs</strong>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Recent logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure recent logs polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
</p>
@if (_status.LogsConfigured)
{
<p class="text-muted small mb-1"><code>@_status.LogsCommand</code></p>
}
@if (_status.LogsConfigured)
{
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
}
</div>
}
@@ -0,0 +1,81 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
/// <summary>
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
/// </summary>
public partial class LogsSection : IDisposable
{
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private int _intervalSeconds;
private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshAsync();
StateHasChanged();
}
private async Task RefreshAsync()
{
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
}
catch (OperationCanceledException) { }
catch
{
// Don't overwrite on background refresh
}
finally
{
_refreshing = false;
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,23 @@
@namespace DnsmasqWebUI.Components.Home
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status?.StatusShowConfigured == true)
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Service status</strong>
<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>
</button>
</p>
<p class="text-muted small mb-1"><code>@_status.StatusShowCommand</code></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
<div class="mt-2">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
</div>
}
@@ -0,0 +1,81 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
/// <summary>
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
/// </summary>
public partial class StatusSection : IDisposable
{
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private int _intervalSeconds;
private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshAsync();
StateHasChanged();
}
private async Task RefreshAsync()
{
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
}
catch (OperationCanceledException) { }
catch
{
// Don't overwrite on background refresh
}
finally
{
_refreshing = false;
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_cts.Cancel();
_cts.Dispose();
}
}
@@ -3,9 +3,7 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center">
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="Local DNS home">
<span class="brand-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<i class="bi bi-globe brand-icon" aria-hidden="true"></i>
<span>Local DNS</span>
</a>
</div>
@@ -17,17 +15,17 @@
<nav class="nav flex-column" aria-label="Main navigation">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Overview
<i class="bi bi-house-door-fill nav-icon" aria-hidden="true"></i> Overview
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="hosts">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts
<i class="bi bi-list-nested nav-icon" aria-hidden="true"></i> Hosts
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dhcp">
<span class="bi bi-hdd-network-nav-menu" aria-hidden="true"></span> DHCP
<i class="bi bi-hdd-network nav-icon" aria-hidden="true"></i> DHCP
</NavLink>
</div>
</nav>
@@ -34,38 +34,17 @@
.brand-icon {
display: inline-flex;
flex-shrink: 0;
}
.brand-icon svg {
width: 1.5rem;
height: 1.5rem;
color: rgba(255, 255, 255, 0.95);
font-size: 1.5rem;
vertical-align: -0.125em;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
/* Bootstrap Icons in nav: white, sized to match link text */
.nav-item .nav-icon {
color: rgba(255, 255, 255, 0.95);
font-size: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-hdd-network-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm2 7a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2.5.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z'/%3E%3Cpath d='M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1v3H2a2 2 0 0 1-2 2H1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1h-1a2 2 0 0 1-2 2h-3v-3h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm13 2h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM4 5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5zm7 4a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V9z'/%3E%3C/svg%3E");
vertical-align: -0.125em;
}
.nav-item {
@@ -0,0 +1,48 @@
@namespace DnsmasqWebUI.Components.Modals
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@inject IClientSettingsService ClientSettingsService
@if (IsVisible)
{
<dialog @ref="_dialogRef" class="settings-modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div>
<div class="modal-body">
@if (SettingsContext == SettingsModalContext.All)
{
<div class="mb-3">
<label class="form-label">Search settings</label>
<input type="search" class="form-control" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" />
</div>
}
@if (ShouldShowSection("ServiceStatus"))
{
<section class="mb-4">
<h6 class="text-muted">Service status</h6>
<label for="status-interval" class="form-label">Polling interval (seconds)</label>
<input id="status-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection("Logs"))
{
<section class="mb-4">
<h6 class="text-muted">Recent logs</h6>
<label for="logs-interval" class="form-label">Polling interval (seconds)</label>
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
</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">Save</button>
</div>
</div>
</dialog>
}
@@ -0,0 +1,134 @@
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Client.Services.Abstractions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Modals;
/// <summary>
/// Modal for editing client-side settings. Uses native HTML &lt;dialog&gt; via JS interop.
/// Implements IAsyncDisposable: disposes <see cref="IJSObjectReference"/> and <see cref="DotNetObjectReference{T}"/> when the component is removed.
/// </summary>
public partial class SettingsModal : IAsyncDisposable
{
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private string _searchTerm = string.Empty;
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
[Parameter] public bool IsVisible { get; set; }
[Parameter] public string Title { get; set; } = "Client settings";
[Parameter] public SettingsModalContext SettingsContext { get; set; } = SettingsModalContext.All;
[Parameter] public EventCallback OnClose { get; set; }
protected override async Task OnParametersSetAsync()
{
var justOpened = IsVisible && !_wasVisible;
if (justOpened)
_dialogInitialized = false;
_wasVisible = IsVisible;
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
if (justOpened)
{
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds
};
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
if (IsVisible && _moduleLoaded && _jsModule != null)
{
if (!_dialogInitialized)
{
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
_dialogInitialized = true;
}
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
}
}
private bool ShouldShowSection(string key)
{
if (SettingsContext == SettingsModalContext.ServicePolling)
return key == "ServiceStatus";
if (SettingsContext == SettingsModalContext.LogsPolling)
return key == "Logs";
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),
_ => false
};
}
return false;
}
private async Task Save()
{
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp(
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
private async Task Close()
{
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
[JSInvokable]
public async Task OnDialogClosed(string returnValue)
{
await OnClose.InvokeAsync();
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Disposes JS module and DotNetObjectReference. Blazor calls this when the component is removed from the tree.
/// </summary>
public async ValueTask DisposeAsync()
{
try
{
if (_jsModule != null)
await _jsModule.DisposeAsync();
}
finally
{
_jsModule = null;
}
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
@@ -0,0 +1,35 @@
.settings-modal-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.settings-modal-dialog .modal-content {
max-width: 28rem;
margin: auto;
}
.settings-modal-dialog {
border: none;
border-radius: 0.375rem;
padding: 0;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.settings-modal-dialog .modal-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
.settings-modal-dialog .modal-title {
margin: 0;
font-size: 1.25rem;
}
.settings-modal-dialog .modal-body {
padding: 1.25rem;
}
.settings-modal-dialog .modal-footer {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem;
}
+4 -1
View File
@@ -13,7 +13,10 @@
@if (_status == null)
{
<p>Loading...</p>
<p class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</p>
}
else
{
+31 -98
View File
@@ -4,6 +4,7 @@
@inject IStatusClient StatusClient
@inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@implements IDisposable
<PageTitle>Overview | Local DNS</PageTitle>
@@ -56,56 +57,10 @@ else if (_status != null)
</div>
}
@* Full service status — output of StatusShowCommand *@
@if (_status.StatusShowConfigured)
{
<p class="mb-1 mt-3"><strong>Service status</strong></p>
<p class="text-muted small mb-1"><code>@_status.StatusShowCommand</code></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
@* Recent logs — live view from LogsCommand (show command so it's clear what produces the output) *@
@if (_status.LogsConfigured || _status.LogsPath != null)
{
<p class="mb-1 mt-3"><strong>Recent logs</strong></p>
@if (_status.LogsConfigured)
{
<p class="text-muted small mb-1"><code>@_status.LogsCommand</code> <span class="text-muted">(refreshes every @(_refreshIntervalSeconds)s)</span></p>
}
@if (_status.LogsConfigured)
{
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
}
}
@if (_status.StatusShowConfigured || _status.LogsConfigured)
{
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
}
</div>
}
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
<section class="page-section">
@@ -201,19 +156,22 @@ else if (_status != null)
}
@code {
private const int _refreshIntervalSeconds = 15;
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;
private string? _error;
private bool _loading = true;
private bool _reloading;
private bool _refreshing;
private string? _reloadMessage;
private string? _reloadDetail;
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
private Timer? _refreshTimer;
protected override async Task OnInitializedAsync()
{
@@ -222,6 +180,9 @@ else if (_status != null)
{
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);
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -233,14 +194,6 @@ else if (_status != null)
{
// Config set is optional for overview
}
if (_status.StatusShowConfigured || _status.LogsConfigured)
{
_refreshTimer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_refreshIntervalSeconds),
TimeSpan.FromSeconds(_refreshIntervalSeconds));
}
}
}
catch (OperationCanceledException)
@@ -257,43 +210,9 @@ else if (_status != null)
}
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshStatus();
StateHasChanged();
}
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling");
private async Task RefreshStatus()
{
if (_status == null) return;
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
try
{
_configSet = await ConfigSetClient.GetConfigSetAsync(token);
}
catch
{
// Config set is optional
}
}
}
catch (OperationCanceledException) { }
catch (Exception)
{
// Don't overwrite _error on background refresh
}
finally
{
_refreshing = false;
}
}
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
private static string GetStatusLabel(DnsmasqServiceStatus s)
{
@@ -356,10 +275,24 @@ 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()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
_cts.Cancel();
_cts.Dispose();
}
@@ -10,7 +10,10 @@
@if (_status == null)
{
<p>Loading...</p>
<p class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</p>
}
else if (_status.NoHosts && !_pathInAddnHosts)
{
+5 -1
View File
@@ -7,7 +7,11 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using DnsmasqWebUI
@using DnsmasqWebUI.Client.Models
@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.Home
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Http.Clients
@using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions