mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,14 +47,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
var module = await GetModuleAsync();
|
||||
if (module == null) return new ClientSettings();
|
||||
var json = await module.InvokeAsync<string?>("getItem");
|
||||
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
var defaults = new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(defaults);
|
||||
return defaults;
|
||||
}
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
|
||||
var settings = JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(settings);
|
||||
return settings;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ClientSettings();
|
||||
var defaults = new ClientSettings();
|
||||
ClientSettingsFields.HydrateFrom(defaults);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace DnsmasqWebUI.Models.Client.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Generic base for client settings fields. Holds the value and provides coercion on set.
|
||||
/// </summary>
|
||||
public abstract class ClientSettingsField<T> : IClientSettingsField
|
||||
{
|
||||
public string DisplayName { get; }
|
||||
private T _value = default!;
|
||||
|
||||
public virtual T Value
|
||||
{
|
||||
get => _value;
|
||||
set => _value = CoerceValue(value);
|
||||
}
|
||||
|
||||
object? IClientSettingsField.Value
|
||||
{
|
||||
get => Value;
|
||||
set => Value = (T)value!;
|
||||
}
|
||||
|
||||
public abstract string? Validate(T value);
|
||||
|
||||
string? IClientSettingsField.Validate() => Validate(Value);
|
||||
|
||||
protected virtual T CoerceValue(T value) => value;
|
||||
|
||||
protected ClientSettingsField(string displayName)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DnsmasqWebUI.Models.Client.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic interface for heterogeneous iteration over client settings fields.
|
||||
/// </summary>
|
||||
public interface IClientSettingsField
|
||||
{
|
||||
string DisplayName { get; }
|
||||
object? Value { get; set; }
|
||||
string? Validate();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using DnsmasqWebUI.Models.Client.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Boolean field. No validation or coercion.
|
||||
/// </summary>
|
||||
public sealed class ClientSettingsBoolField : ClientSettingsField<bool>
|
||||
{
|
||||
public ClientSettingsBoolField(string displayName) : base(displayName) { }
|
||||
|
||||
public override string? Validate(bool value) => null;
|
||||
}
|
||||
@@ -1,23 +1,66 @@
|
||||
using DnsmasqWebUI.Models.Client.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Per-field metadata for client settings: bounds, display names for validation errors.
|
||||
/// Used by SettingsModal (validation on save) and by pages/components (defensive clamp when loading from storage).
|
||||
/// Static field definitions and hydration/serialization helpers.
|
||||
/// </summary>
|
||||
public static class ClientSettingsFields
|
||||
{
|
||||
public sealed record FieldBounds(int Min, int Max, string DisplayName)
|
||||
static ClientSettingsFields()
|
||||
{
|
||||
public bool IsValid(int value) => value >= Min && value <= Max;
|
||||
public int Clamp(int value) => Math.Clamp(value, Min, Max);
|
||||
public string? Validate(int value) =>
|
||||
IsValid(value) ? null : $"{DisplayName} must be between {Min} and {Max} seconds. You entered {value}.";
|
||||
HydrateFrom(new ClientSettings());
|
||||
}
|
||||
|
||||
public static readonly FieldBounds ServiceStatusPollingInterval = new(5, 300, "Service status polling interval");
|
||||
public static readonly FieldBounds RecentLogsPollingInterval = new(5, 300, "Recent logs polling interval");
|
||||
public static readonly FieldBounds AppLogsPollingInterval = new(5, 300, "App logs polling interval");
|
||||
public static readonly FieldBounds LeasesPollingInterval = new(5, 300, "DHCP leases refresh interval");
|
||||
public static readonly FieldBounds RecentLogsMaxLines = new(100, 2000, "Recent logs max lines");
|
||||
public static readonly FieldBounds AppLogsMaxLines = new(100, 2000, "App logs max lines");
|
||||
public static readonly ClientSettingsMinMaxField ServiceStatusPollingInterval = new("Service status polling interval", 5, 300);
|
||||
public static readonly ClientSettingsMinMaxField RecentLogsPollingInterval = new("Recent logs polling interval", 5, 300);
|
||||
public static readonly ClientSettingsMinMaxField AppLogsPollingInterval = new("App logs polling interval", 5, 300);
|
||||
public static readonly ClientSettingsMinMaxField LeasesPollingInterval = new("DHCP leases refresh interval", 5, 300);
|
||||
public static readonly ClientSettingsMinMaxField RecentLogsMaxLines = new("Recent logs max lines", 100, 2000, "lines");
|
||||
public static readonly ClientSettingsMinMaxField AppLogsMaxLines = new("App logs max lines", 100, 2000, "lines");
|
||||
public static readonly ClientSettingsBoolField RecentLogsAutoScroll = new("Recent logs auto-scroll");
|
||||
public static readonly ClientSettingsBoolField AppLogsAutoScroll = new("App logs auto-scroll");
|
||||
|
||||
private static readonly (string Section, IClientSettingsField Field)[] ValidationMap =
|
||||
[
|
||||
(SettingsModalSections.ServiceStatus, ServiceStatusPollingInterval),
|
||||
(SettingsModalSections.Logs, RecentLogsPollingInterval),
|
||||
(SettingsModalSections.AppLogs, AppLogsPollingInterval),
|
||||
(SettingsModalSections.Leases, LeasesPollingInterval),
|
||||
(SettingsModalSections.RecentLogsDisplay, RecentLogsMaxLines),
|
||||
(SettingsModalSections.RecentLogsDisplay, RecentLogsAutoScroll),
|
||||
(SettingsModalSections.AppLogsDisplay, AppLogsMaxLines),
|
||||
(SettingsModalSections.AppLogsDisplay, AppLogsAutoScroll),
|
||||
];
|
||||
|
||||
public static IReadOnlyList<IClientSettingsField> All => [ServiceStatusPollingInterval, RecentLogsPollingInterval, AppLogsPollingInterval, LeasesPollingInterval, RecentLogsMaxLines, AppLogsMaxLines, RecentLogsAutoScroll, AppLogsAutoScroll];
|
||||
|
||||
public static IReadOnlyList<(string Section, IClientSettingsField Field)> ValidationChecks => ValidationMap;
|
||||
|
||||
public static void HydrateFrom(ClientSettings dto)
|
||||
{
|
||||
ServiceStatusPollingInterval.Value = dto.ServiceStatusPollingIntervalSeconds;
|
||||
RecentLogsPollingInterval.Value = dto.RecentLogsPollingIntervalSeconds;
|
||||
AppLogsPollingInterval.Value = dto.AppLogsPollingIntervalSeconds;
|
||||
LeasesPollingInterval.Value = dto.LeasesPollingIntervalSeconds;
|
||||
RecentLogsMaxLines.Value = dto.RecentLogsMaxLines;
|
||||
RecentLogsAutoScroll.Value = dto.RecentLogsAutoScroll;
|
||||
AppLogsMaxLines.Value = dto.AppLogsMaxLines;
|
||||
AppLogsAutoScroll.Value = dto.AppLogsAutoScroll;
|
||||
}
|
||||
|
||||
public static ClientSettings ToDto()
|
||||
{
|
||||
return new ClientSettings
|
||||
{
|
||||
ServiceStatusPollingIntervalSeconds = ServiceStatusPollingInterval.Value,
|
||||
RecentLogsPollingIntervalSeconds = RecentLogsPollingInterval.Value,
|
||||
AppLogsPollingIntervalSeconds = AppLogsPollingInterval.Value,
|
||||
LeasesPollingIntervalSeconds = LeasesPollingInterval.Value,
|
||||
RecentLogsMaxLines = RecentLogsMaxLines.Value,
|
||||
RecentLogsAutoScroll = RecentLogsAutoScroll.Value,
|
||||
AppLogsMaxLines = AppLogsMaxLines.Value,
|
||||
AppLogsAutoScroll = AppLogsAutoScroll.Value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using DnsmasqWebUI.Models.Client.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Min/max bounded integer field with optional clamping on set.
|
||||
/// </summary>
|
||||
public sealed class ClientSettingsMinMaxField : ClientSettingsField<int>
|
||||
{
|
||||
public int Min { get; }
|
||||
public int Max { get; }
|
||||
public string Unit { get; }
|
||||
public bool AllowClamp { get; }
|
||||
|
||||
public ClientSettingsMinMaxField(string displayName, int min, int max, string unit = "seconds", bool allowClamp = true)
|
||||
: base(displayName)
|
||||
{
|
||||
Min = min;
|
||||
Max = max;
|
||||
Unit = unit;
|
||||
AllowClamp = allowClamp;
|
||||
}
|
||||
|
||||
public override string? Validate(int value)
|
||||
{
|
||||
if (value < Min || value > Max)
|
||||
return $"{DisplayName} must be between {Min} and {Max} {Unit}. You entered {value}.";
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override int CoerceValue(int value) =>
|
||||
AllowClamp ? Math.Clamp(value, Min, Max) : value;
|
||||
}
|
||||
Reference in New Issue
Block a user