Refactor client settings fields, fix logs overwrite, improve settings modal

- Replace FieldBounds with generic ClientSettingsField<T> abstraction
- Add IClientSettingsField, ClientSettingsMinMaxField, ClientSettingsBoolField
- Put abstractions in Models/Client/Abstractions namespace
- Fields hold values; CoerceValue for clamping on set
- ClientSettingsService hydrates fields on load; ToDto for save
- Settings modal binds directly to field values, collapsible only for All context
- Add LogsPreView with ShouldRender freeze to prevent Blazor overwriting JS-owned logs DOM
- Add left margin to static group heading in single-section mode
This commit is contained in:
2026-02-11 08:28:44 +10:00
parent 5ac2006b80
commit cefc488712
18 changed files with 258 additions and 129 deletions
@@ -30,4 +30,4 @@
</span>
</p>
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="app-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
@@ -18,6 +17,7 @@ public partial class AppLogsSection : IAsyncDisposable
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
private bool _justUpdated;
private bool _logsContentReceived;
private int _intervalSeconds;
private string _logLevel = "Information";
private bool _logLevelDisabled;
@@ -42,16 +42,15 @@ public partial class AppLogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
_hubConnection = new HubConnectionBuilder()
@@ -144,6 +143,7 @@ public partial class AppLogsSection : IAsyncDisposable
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
}
@@ -0,0 +1,11 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
@code {
[Parameter] public string Id { get; set; } = "";
[Parameter] public string Placeholder { get; set; } = "Connecting…";
[Parameter] public bool Freeze { get; set; }
protected override bool ShouldRender() => !Freeze;
}
@@ -24,7 +24,7 @@
}
@if (_status.LogsConfigured)
{
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
@@ -18,6 +17,7 @@ public partial class LogsSection : IAsyncDisposable
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private bool _justUpdated;
private bool _logsContentReceived;
private int _intervalSeconds;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
@@ -42,16 +42,15 @@ public partial class LogsSection : IAsyncDisposable
protected override void OnParametersSet()
{
_status = Status;
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
_status = Status;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
@@ -130,6 +129,7 @@ public partial class LogsSection : IAsyncDisposable
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
_logsContentReceived = true;
SetJustUpdated();
StateHasChanged();
}
@@ -1,5 +1,4 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.AspNetCore.Components;
@@ -26,7 +25,7 @@ public partial class StatusSection : IDisposable
protected override async Task OnInitializedAsync()
{
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
_intervalSeconds = RefreshIntervalSeconds;
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -37,9 +36,8 @@ public partial class StatusSection : IDisposable
protected override void OnParametersSet()
{
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
if (RefreshIntervalSeconds == _intervalSeconds) return;
_intervalSeconds = RefreshIntervalSeconds;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -35,15 +35,23 @@
}
@foreach (var group in GetVisibleGroups())
{
var isExpanded = IsGroupExpanded(group);
var isAllSections = SettingsContext == SettingsModalContext.All;
var isExpanded = isAllSections ? IsGroupExpanded(group) : true;
<div class="settings-group">
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
@onclick="() => ToggleGroup(group.Id)"
aria-expanded="@isExpanded"
aria-controls="group-@group.Id">
<span class="settings-group-title">@group.DisplayName</span>
<span class="settings-group-chevron" aria-hidden="true"></span>
</button>
@if (isAllSections)
{
<button type="button" class="settings-group-header @(isExpanded ? "expanded" : "")"
@onclick="() => ToggleGroup(group.Id)"
aria-expanded="@isExpanded"
aria-controls="group-@group.Id">
<span class="settings-group-title">@group.DisplayName</span>
<span class="settings-group-chevron" aria-hidden="true"></span>
</button>
}
else
{
<h6 class="settings-group-title settings-group-title-static mb-2 mt-3">@group.DisplayName</h6>
}
<div id="group-@group.Id" class="settings-group-content @(isExpanded ? "expanded" : "")"
role="region" aria-label="@group.DisplayName">
@foreach (var key in group.SectionKeys)
@@ -56,57 +64,69 @@
case SettingsModalSections.ServiceStatus:
{
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
var field = ClientSettingsFields.ServiceStatusPollingInterval;
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
<input id="status-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Logs:
{
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
var field = ClientSettingsFields.RecentLogsPollingInterval;
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
<input id="logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.AppLogs:
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
var field = ClientSettingsFields.AppLogsPollingInterval;
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
<input id="app-logs-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.Leases:
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
var field = ClientSettingsFields.LeasesPollingInterval;
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
<input id="leases-interval" type="number" class="form-control" min="@field.Min" max="@field.Max" step="1"
@bind="field.Value" />
break;
}
case SettingsModalSections.RecentLogsDisplay:
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
@bind="_editingSettings.RecentLogsMaxLines" />
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
{
var maxLinesField = ClientSettingsFields.RecentLogsMaxLines;
var autoScrollField = ClientSettingsFields.RecentLogsAutoScroll;
<div class="mb-2">
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
<input id="recent-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
case SettingsModalSections.AppLogsDisplay:
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
@bind="_editingSettings.AppLogsMaxLines" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
{
var maxLinesField = ClientSettingsFields.AppLogsMaxLines;
var autoScrollField = ClientSettingsFields.AppLogsAutoScroll;
<div class="mb-2">
<label for="app-logs-max-lines" class="form-label">Max lines</label>
<input id="app-logs-max-lines" type="number" class="form-control" min="@maxLinesField.Min" max="@maxLinesField.Max" step="1"
@bind="maxLinesField.Value" />
</div>
<div class="form-check">
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="autoScrollField.Value" />
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
</div>
break;
}
}
</div>
}
@@ -15,7 +15,6 @@ public partial class SettingsModal : IAsyncDisposable
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty;
private HashSet<string> _expandedGroupIds = new(StringComparer.OrdinalIgnoreCase);
@@ -46,18 +45,7 @@ public partial class SettingsModal : IAsyncDisposable
if (visible.Count > 0)
_expandedGroupIds.Add(visible[0].Id);
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
};
await ClientSettingsService.LoadSettingsAsync();
}
}
@@ -120,18 +108,9 @@ public partial class SettingsModal : IAsyncDisposable
private void RunValidation()
{
_validationErrors.Clear();
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
foreach (var (section, field) in ClientSettingsFields.ValidationChecks)
{
(SettingsModalSections.ServiceStatus, _editingSettings.ServiceStatusPollingIntervalSeconds, ClientSettingsFields.ServiceStatusPollingInterval),
(SettingsModalSections.Logs, _editingSettings.RecentLogsPollingIntervalSeconds, ClientSettingsFields.RecentLogsPollingInterval),
(SettingsModalSections.AppLogs, _editingSettings.AppLogsPollingIntervalSeconds, ClientSettingsFields.AppLogsPollingInterval),
(SettingsModalSections.Leases, _editingSettings.LeasesPollingIntervalSeconds, ClientSettingsFields.LeasesPollingInterval),
(SettingsModalSections.RecentLogsDisplay, _editingSettings.RecentLogsMaxLines, ClientSettingsFields.RecentLogsMaxLines),
(SettingsModalSections.AppLogsDisplay, _editingSettings.AppLogsMaxLines, ClientSettingsFields.AppLogsMaxLines),
};
foreach (var (section, value, bounds) in checks)
{
if (ShouldShowSection(section) && bounds.Validate(value) is { } err)
if (ShouldShowSection(section) && field.Validate() is { } err)
_validationErrors.Add(err);
}
}
@@ -145,7 +124,7 @@ public partial class SettingsModal : IAsyncDisposable
return;
}
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await OnClose.InvokeAsync();
}
@@ -86,6 +86,10 @@
transform: rotate(135deg);
}
.settings-group-title-static {
margin-left: 0.75rem;
}
.settings-group-content {
max-height: 0;
overflow: hidden;
+4 -5
View File
@@ -417,8 +417,8 @@ else
_message = null;
try
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
@@ -436,10 +436,9 @@ else
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
await InvokeAsync(() =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
@@ -78,21 +78,14 @@ else if (_status != null)
</div>
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection Status="_status" RefreshIntervalSeconds="_recentLogsIntervalSeconds" LogsMaxLines="_recentLogsMaxLines" LogsAutoScroll="_recentLogsAutoScroll" OnOpenSettings="OpenRecentLogsSettings" />
<AppLogsSection RefreshIntervalSeconds="_appLogsIntervalSeconds" LogsMaxLines="_appLogsMaxLines" LogsAutoScroll="_appLogsAutoScroll" OnOpenSettings="OpenAppLogsSettings" />
<StatusSection RefreshIntervalSeconds="@ClientSettingsFields.ServiceStatusPollingInterval.Value" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection Status="_status" RefreshIntervalSeconds="@ClientSettingsFields.RecentLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.RecentLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.RecentLogsAutoScroll.Value" OnOpenSettings="OpenRecentLogsSettings" />
<AppLogsSection RefreshIntervalSeconds="@ClientSettingsFields.AppLogsPollingInterval.Value" LogsMaxLines="@ClientSettingsFields.AppLogsMaxLines.Value" LogsAutoScroll="@ClientSettingsFields.AppLogsAutoScroll.Value" OnOpenSettings="OpenAppLogsSettings" />
</section>
}
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15;
private int _appLogsIntervalSeconds = 15;
private int _recentLogsMaxLines = 500;
private bool _recentLogsAutoScroll = true;
private int _appLogsMaxLines = 500;
private bool _appLogsAutoScroll = true;
private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet;
@@ -117,14 +110,7 @@ else if (_status != null)
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
await ClientSettingsService.LoadSettingsAsync();
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -161,18 +147,7 @@ else if (_status != null)
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
StateHasChanged();
});
await InvokeAsync(StateHasChanged);
}
private async Task ReloadDnsmasq()
@@ -8,6 +8,7 @@
@using Microsoft.JSInterop
@using DnsmasqWebUI
@using DnsmasqWebUI.Models.Client
@using DnsmasqWebUI.Models.Client.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals