mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-22 01:42:08 +10:00
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:
@@ -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 <dialog> 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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 & 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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user