App logs, client settings, NavMenu layout

- Add real-time app logs via SignalR (log level, category filters)
- Add filters modal with restore defaults; sentinel for empty arrays
- Add client settings: max lines and auto-scroll per log panel
- Improve NavMenu: align settings + hamburger, smaller mobile logo
- LogsService, LogsHub, LoggingController, AppLogsLoggerProvider
- Persist log level and filters to appsettings.Overrides.json
This commit is contained in:
2026-02-10 08:44:30 +10:00
parent b70aa02017
commit 1bdd176c18
66 changed files with 2312 additions and 201 deletions
+3 -3
View File
@@ -29,14 +29,14 @@ jobs:
run: |
docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:9.0 \
mcr.microsoft.com/dotnet/sdk:10.0 \
./scripts/publish-self-contained.sh ${{ matrix.rid }}
- name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
dotnet-version: '10.0.x'
- name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -44,7 +44,7 @@ jobs:
- name: Zip publish output
run: |
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish"
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
- name: Upload artifact
+2 -1
View File
@@ -43,6 +43,7 @@ testdata-mount/
.DS_Store
Thumbs.db
# User-specific
# User-specific / runtime
appsettings.*.local.json
appsettings.Overrides.json
*.local
+2 -2
View File
@@ -3,12 +3,12 @@
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
+1 -1
View File
@@ -385,7 +385,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64
```
**Output:** `src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/`
**Output:** `src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/`
---
+2
View File
@@ -26,6 +26,8 @@ services:
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
# Persist runtime log level across container restarts
RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json"
volumes:
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
networks:
+2 -2
View File
@@ -346,7 +346,7 @@ do_install_from_source() {
exit 1
fi
if ! command -v dotnet >/dev/null 2>&1; then
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2
exit 1
fi
@@ -371,7 +371,7 @@ do_install_from_source() {
echo "Error: Could not determine RID from build output." >&2
exit 1
fi
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish"
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish"
if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
echo "Error: Build output not found at $publish_dir" >&2
exit 1
+15 -16
View File
@@ -3,7 +3,7 @@
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
#
# What this script does:
# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data,
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
# then docker compose up -d [--build] [--force-recreate].
# --stop: docker compose down (stop and remove containers/networks).
# --tidy: docker compose down, then clear the mount directory for a clean next run.
@@ -18,7 +18,7 @@ MOUNT_DIR=""
PREPARE_ONLY=false
BUILD=false
RECREATE=false
NO_CLEAR=false
CLEAR=false
STOP=false
TIDY=false
@@ -26,10 +26,10 @@ usage() {
echo "Usage: $0 [OPTIONS] [--]"
echo ""
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness"
echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)."
echo "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)."
echo ""
echo "Steps (when starting):"
echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount."
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
echo ""
@@ -39,9 +39,8 @@ usage() {
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
echo ""
echo "Mount behaviour:"
echo " (default) Clear mount dir completely, then sync. Use for a clean run."
echo " --no-clear Do not clear mount dir; only sync over existing contents."
echo " Use to preserve leases or debug files between runs."
echo " (default) Preserve mount dir; sync source over existing contents."
echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo ""
echo "Compose behaviour:"
echo " --prepare-only Only prepare the mount; do not run docker compose."
@@ -62,19 +61,19 @@ usage() {
echo ""
echo "Examples:"
echo " $0"
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
echo " Sync testdata to mount (preserving existing), start containers (no rebuild)."
echo ""
echo " $0 --build"
echo " Clear and sync, then rebuild images and start containers."
echo " Sync, rebuild images, and start containers."
echo ""
echo " $0 --recreate"
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
echo " Sync, then up -d --force-recreate (fresh containers)."
echo ""
echo " $0 --no-clear"
echo " Preserve mount contents, sync over it, start (no rebuild)."
echo " $0 --clear"
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
echo ""
echo " $0 --prepare-only"
echo " Only clear and sync testdata -> testdata-mount; no containers."
echo " Only sync testdata -> testdata-mount; no containers."
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
echo ""
echo " $0 --source myfixtures --mount mymount --prepare-only"
@@ -106,8 +105,8 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1"
shift
;;
--no-clear)
NO_CLEAR=true
--clear)
CLEAR=true
shift
;;
--prepare-only)
@@ -189,7 +188,7 @@ docker compose -f "$COMPOSE_FILE" down
mkdir -p "$MOUNT_DIR"
if [ "$NO_CLEAR" = false ]; then
if [ "$CLEAR" = true ]; then
echo "Clearing mount directory: $MOUNT_DIR"
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
fi
+2 -2
View File
@@ -85,7 +85,7 @@ while [ $# -gt 0 ]; do
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
echo ""
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
echo ""
echo "Examples:"
echo " $0 # Publish for current machine (auto-detect RID)"
@@ -163,7 +163,7 @@ else
--self-contained true
fi
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish"
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
echo ""
echo "Done. Output: $OUT_DIR"
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -0,0 +1,46 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@inject ILoggingClient LoggingClient
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (IsVisible)
{
<dialog @ref="_dialogRef" class="settings-modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">App logs filters</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">Logger categories matching these prefixes are excluded from the app logs display.</p>
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
</div>
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
@foreach (var prefix in _prefixes)
{
<li class="list-group-item d-flex align-items-center justify-content-between py-1 px-2">
<code class="small text-break">@prefix</code>
<button type="button" class="btn btn-link btn-sm p-0 text-danger" aria-label="Remove" @onclick="() => RemovePrefix(prefix)">
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</li>
}
@if (_prefixes.Count == 0)
{
<li class="list-group-item text-muted small py-2">No filters. All categories are shown.</li>
}
</ul>
</div>
<div class="modal-footer d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
<div>
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
</div>
</div>
</div>
</dialog>
}
@@ -0,0 +1,154 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
public partial class AppLogsFiltersModal : IAsyncDisposable
{
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<AppLogsFiltersModal>? _dotNetRef;
private List<string> _prefixes = [];
private List<string> _originalPrefixes = [];
private string _newPrefix = string.Empty;
private bool _saving;
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
private readonly CancellationTokenSource _cts = new();
[Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[JSInvokable]
public async Task OnDialogClosed(string returnValue)
{
await OnClose.InvokeAsync();
}
protected override async Task OnParametersSetAsync()
{
var justOpened = IsVisible && !_wasVisible;
_wasVisible = IsVisible;
if (justOpened)
{
_dialogInitialized = false;
try
{
var filters = await LoggingClient.GetFiltersAsync(_cts.Token);
_prefixes = filters.ToList();
_originalPrefixes = filters.ToList();
}
catch (OperationCanceledException) { }
catch { /* leave current */ }
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
if (IsVisible && _moduleLoaded && _jsModule != null)
{
if (!_dialogInitialized)
{
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
_dialogInitialized = true;
}
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
}
}
private async Task Close()
{
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
private void AddPrefix()
{
var p = _newPrefix?.Trim();
if (string.IsNullOrEmpty(p) || _prefixes.Contains(p, StringComparer.Ordinal)) return;
_prefixes.Add(p);
_newPrefix = string.Empty;
}
private void RemovePrefix(string prefix)
{
_prefixes.Remove(prefix);
}
private void OnNewPrefixKeydown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
AddPrefix();
}
}
private async Task Save()
{
_saving = true;
StateHasChanged();
try
{
await LoggingClient.SetFiltersAsync(_prefixes, _cts.Token);
_originalPrefixes = [.. _prefixes];
await Close();
}
catch (OperationCanceledException) { }
catch { /* leave modal open */ }
finally
{
_saving = false;
StateHasChanged();
}
}
private async Task RestoreDefaults()
{
_saving = true;
StateHasChanged();
try
{
var defaults = await LoggingClient.RestoreFilterDefaultsAsync(_cts.Token);
_prefixes = defaults.ToList();
_originalPrefixes = [.. _prefixes];
await Close();
}
catch (OperationCanceledException) { }
catch { /* leave modal open */ }
finally
{
_saving = false;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
_dotNetRef?.Dispose();
if (_jsModule != null)
{
try { await _jsModule.DisposeAsync(); }
catch { /* ignore */ }
}
}
}
@@ -0,0 +1,47 @@
/* App logs filters modal – scoped styles; root is the dialog */
.settings-modal-dialog {
border: none;
border-radius: 0.5rem;
padding: 0;
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
}
.settings-modal-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
max-width: 28rem;
margin: auto;
}
.modal-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.modal-title {
margin: 0;
font-size: 1.25rem;
}
.modal-body {
padding: 1.25rem;
}
.modal-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.modal-footer .btn {
min-width: 4.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
}
@@ -0,0 +1,33 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* App logs — live view via SignalR from ILogger *@
@using DnsmasqWebUI.Models.Logs
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject ILogger<AppLogsSection> Logger
@implements IAsyncDisposable
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
<span class="d-flex align-items-center gap-1 flex-wrap">
<strong>App logs</strong>
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
</span>
<span class="d-flex align-items-center gap-1 flex-wrap">
<select class="form-select form-select-sm" style="width: auto;" aria-label="Log level" @bind="_logLevel" @bind:after="OnLogLevelChanged" disabled="@_logLevelDisabled">
<option value="Trace">Trace</option>
<option value="Debug">Debug</option>
<option value="Information">Information</option>
<option value="Warning">Warning</option>
<option value="Error">Error</option>
<option value="Critical">Critical</option>
</select>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs category filters" @onclick="OpenFiltersModal" title="Configure category filters">
<i class="bi bi-funnel-fill bi-gear-sm" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure app logs polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
</span>
</p>
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
@@ -0,0 +1,195 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
/// <summary>
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
/// Snapshot on connect; RequestAppLogsUpdate on poll. Log level is configurable at runtime (persisted to appsettings.Overrides.json).
/// </summary>
public partial class AppLogsSection : IAsyncDisposable
{
private const string AppLogsPreId = "app-logs-pre";
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
private bool _justUpdated;
private int _intervalSeconds;
private string _logLevel = "Information";
private bool _logLevelDisabled;
private bool _filtersModalVisible;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
private Timer? _pollTimer;
private Timer? _justUpdatedResetTimer;
private readonly CancellationTokenSource _cts = new();
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public int LogsMaxLines { get; set; } = 500;
[Parameter] public bool LogsAutoScroll { get; set; } = true;
[Parameter] public EventCallback OnOpenSettings { get; set; }
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
protected override void OnParametersSet()
{
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUri)
.WithAutomaticReconnect()
.Build();
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
try
{
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (prerender)"); return; }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (circuit disconnected)"); return; }
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import failed"); return; }
await _hubConnection.StartAsync(_cts.Token);
RestartPollTimer();
try
{
_logLevel = await LoggingClient.GetLevelAsync(_cts.Token);
}
catch (OperationCanceledException) { }
catch (Exception) { /* leave default */ }
// Request initial snapshot (replace)
try
{
await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token);
}
catch (OperationCanceledException) { }
catch (Exception) { /* ignore */ }
}
private void OpenFiltersModal() => _filtersModalVisible = true;
private void CloseFiltersModal() => _filtersModalVisible = false;
private async Task OnLogLevelChanged()
{
if (string.IsNullOrEmpty(_logLevel)) return;
_logLevelDisabled = true;
try
{
var updated = await LoggingClient.SetLevelAsync(_logLevel, _cts.Token);
_logLevel = updated;
}
catch (OperationCanceledException) { }
catch (Exception) { /* leave binding as-is */ }
finally
{
_logLevelDisabled = false;
StateHasChanged();
}
}
private void RestartPollTimer()
{
_pollTimer?.Dispose();
_pollTimer = new Timer(
_ => _ = InvokeAsync(PollTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task PollTick()
{
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
return;
try
{
await _hubConnection.InvokeAsync("RequestAppLogsUpdate", _cts.Token);
}
catch (OperationCanceledException) { }
catch (Exception) { /* ignore */ }
}
private async void OnAppLogsUpdate(LogsUpdatePayload payload)
{
try
{
await InvokeAsync(async () =>
{
if (_logsJs == null) return;
try
{
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
SetJustUpdated();
StateHasChanged();
}
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
catch (JSException) { /* JS error; ignore */ }
});
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
}
private void SetJustUpdated()
{
_justUpdated = true;
_justUpdatedResetTimer?.Dispose();
_justUpdatedResetTimer = new Timer(_ =>
{
_justUpdatedResetTimer?.Dispose();
_justUpdatedResetTimer = null;
_ = InvokeAsync(() =>
{
_justUpdated = false;
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
}
public async ValueTask DisposeAsync()
{
_pollTimer?.Dispose();
_justUpdatedResetTimer?.Dispose();
_cts.Cancel();
_cts.Dispose();
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
if (_logsJs != null)
{
try { await _logsJs.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
_logsJs = null;
}
}
}
@@ -0,0 +1,13 @@
/* Status dot pulse – shared with StatusSection, LogsSection */
@keyframes status-dot-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.status-dot.status-dot-updating {
animation: status-dot-pulse 0.7s ease-in-out infinite;
}
@@ -1,9 +1,19 @@
@namespace DnsmasqWebUI.Components.Dnsmasq
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
@using DnsmasqWebUI.Models.Logs
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject ILogger<LogsSection> Logger
@implements IAsyncDisposable
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
<span class="d-flex align-items-center gap-1">
<strong>Recent logs</strong>
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
</span>
<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>
@@ -14,17 +24,10 @@
}
@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>
}
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</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>
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" 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>
@@ -1,81 +1,181 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq;
/// <summary>
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
/// receives DnsmasqLogsUpdate pushes, updates DOM via JS interop.
/// </summary>
public partial class LogsSection : IDisposable
public partial class LogsSection : IAsyncDisposable
{
private const string LogsPreId = "dnsmasq-logs-pre";
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private bool _justUpdated;
private int _intervalSeconds;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
private Timer? _pollTimer;
private Timer? _justUpdatedResetTimer;
private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public int LogsMaxLines { get; set; } = 500;
[Parameter] public bool LogsAutoScroll { get; set; } = true;
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
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));
}
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
_status = Status;
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
RestartPollTimer();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
_status = Status;
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUri)
.WithAutomaticReconnect()
.Build();
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
try
{
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; }
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; }
await _hubConnection.StartAsync(_cts.Token);
RestartPollTimer();
// Initial request
await RequestRefreshAsync();
}
private void RestartPollTimer()
{
_pollTimer?.Dispose();
_pollTimer = new Timer(
_ => _ = InvokeAsync(PollTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task OnRefreshTick()
private async Task PollTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshAsync();
StateHasChanged();
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
return;
await RequestRefreshAsync();
}
private async Task RefreshAsync()
private async Task RequestRefreshAsync()
{
if (_hubConnection?.State != HubConnectionState.Connected)
return;
_refreshing = true;
StateHasChanged();
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
await _hubConnection.InvokeAsync("RequestDnsmasqLogs", _cts.Token);
}
catch (OperationCanceledException) { }
catch
catch (Exception)
{
// Don't overwrite on background refresh
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
public void Dispose()
private async void OnDnsmasqLogsUpdate(LogsUpdatePayload payload)
{
_timer?.Dispose();
_timer = null;
try
{
await InvokeAsync(async () =>
{
if (_logsJs == null) return;
try
{
if (payload.Mode == "replace")
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
else
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
SetJustUpdated();
StateHasChanged();
}
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
catch (JSException) { /* JS error; ignore */ }
});
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
}
private void SetJustUpdated()
{
_justUpdated = true;
_justUpdatedResetTimer?.Dispose();
_justUpdatedResetTimer = new Timer(_ =>
{
_justUpdatedResetTimer?.Dispose();
_justUpdatedResetTimer = null;
_ = InvokeAsync(() =>
{
_justUpdated = false;
StateHasChanged();
});
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
}
public async ValueTask DisposeAsync()
{
_pollTimer?.Dispose();
_justUpdatedResetTimer?.Dispose();
_cts.Cancel();
_cts.Dispose();
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
if (_logsJs != null)
{
try { await _logsJs.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
_logsJs = null;
}
}
}
@@ -0,0 +1,13 @@
/* Status dot pulse – shared with StatusSection */
@keyframes status-dot-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.status-dot.status-dot-updating {
animation: status-dot-pulse 0.7s ease-in-out infinite;
}
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.AspNetCore.Components;
@@ -25,7 +26,7 @@ public partial class StatusSection : IDisposable
protected override async Task OnInitializedAsync()
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
@@ -36,7 +37,7 @@ public partial class StatusSection : IDisposable
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
@@ -3,16 +3,18 @@
@inject ISettingsModalService SettingsModalService
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center">
<div class="top-row-inner">
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
<span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
<span>@AppTitle</span>
</a>
<div class="nav-top-controls">
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
<i class="bi bi-gear-fill" aria-hidden="true"></i>
</button>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
</div>
</div>
</div>
<div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()">
@@ -1,13 +1,34 @@
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
/* Flex row: brand left, controls right; single line, aligned with brand. */
.top-row-inner {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 1rem 0.5rem 0.75rem;
box-sizing: border-box;
}
/* Settings + hamburger: push right, align with brand */
.nav-top-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.navbar-toggler {
appearance: none;
cursor: pointer;
flex-shrink: 0;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 50%;
right: 1rem;
transform: translateY(-50%);
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
@@ -16,17 +37,11 @@
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
position: relative;
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
/* Settings button: right of brand, same visual style as nav. On mobile, leaves space for toggler. */
/* Settings button: same height as toggler on mobile for alignment */
.nav-settings-btn {
margin-left: auto;
margin-right: 0.5rem;
padding: 0.375rem 0.5rem;
height: 2.5rem;
box-sizing: border-box;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
@@ -47,25 +62,29 @@
font-size: 1.25rem;
}
/* On mobile: leave space for toggler (right: 1rem, width 3.5rem) so settings doesn't overlap */
@media (max-width: 640.98px) {
.nav-settings-btn {
margin-right: 4rem;
}
}
.navbar-brand {
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
color: rgba(255, 255, 255, 0.95) !important;
flex-shrink: 0;
}
.navbar-brand:hover {
color: white !important;
}
/* Logo size/alignment: see app.css .sidebar .navbar-brand .brand-logo-wrap / .brand-logo */
/* Smaller logo on mobile so controls align with brand text */
@media (max-width: 640.98px) {
.top-row .navbar-brand .brand-logo-wrap {
width: 2.5rem;
height: 2.5rem;
}
.top-row .navbar-brand .brand-logo {
height: 2.5rem;
}
}
/* Bootstrap Icons in nav: white, sized to match link text */
.nav-item .nav-icon {
@@ -116,7 +135,8 @@
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
display: block;
}
@@ -2,6 +2,7 @@
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@inject IClientSettingsService ClientSettingsService
@inject ILogger<SettingsModal> Logger
@if (IsVisible)
{
@@ -12,6 +13,18 @@
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div>
<div class="modal-body">
@if (_validationErrors.Count > 0)
{
<div class="alert alert-danger mb-3" role="alert">
<strong>Please fix the following:</strong>
<ul class="mb-0 mt-1">
@foreach (var err in _validationErrors)
{
<li>@err</li>
}
</ul>
</div>
}
@if (SettingsContext == SettingsModalContext.All)
{
<div class="mb-3">
@@ -26,7 +39,7 @@
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="5" step="1"
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@@ -36,20 +49,62 @@
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogs))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<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" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.Leases))
{
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<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>
</section>
}
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
{
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
<section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6>
<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>
</section>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
@@ -16,6 +16,7 @@ public partial class SettingsModal : IAsyncDisposable
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty;
private bool _moduleLoaded;
private bool _dialogInitialized;
@@ -39,12 +40,18 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened)
{
_searchTerm = string.Empty;
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
};
}
}
@@ -52,11 +59,17 @@ public partial class SettingsModal : IAsyncDisposable
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: JS import failed"); }
}
if (IsVisible && _moduleLoaded && _jsModule != null)
{
@@ -73,31 +86,48 @@ public partial class SettingsModal : IAsyncDisposable
private bool ShouldShowSection(string key)
{
var focusedKey = SettingsModalSections.GetSectionKeyForContext(SettingsContext);
if (focusedKey != null)
return string.Equals(key, focusedKey, StringComparison.OrdinalIgnoreCase);
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
if (keys != null)
return keys.Any(k => string.Equals(key, k, StringComparison.OrdinalIgnoreCase));
return SettingsModalSections.MatchesSearch(key, _searchTerm);
}
private void RunValidation()
{
_validationErrors.Clear();
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
{
(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)
_validationErrors.Add(err);
}
}
private async Task Save()
{
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp(
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp(
_editingSettings.LeasesPollingIntervalSeconds, 5, 300);
RunValidation();
if (_validationErrors.Count > 0)
{
await InvokeAsync(StateHasChanged);
return;
}
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await OnClose.InvokeAsync();
}
private async Task Close()
{
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await OnClose.InvokeAsync();
}
@@ -112,14 +142,13 @@ public partial class SettingsModal : IAsyncDisposable
/// 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
{
try { await _jsModule.DisposeAsync(); }
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
_jsModule = null;
}
_dotNetRef?.Dispose();
+9 -3
View File
@@ -399,10 +399,16 @@ else
("2592000", "30 days"),
};
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
SettingsModalService.SettingsChanged += OnSettingsChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await Load();
StateHasChanged();
}
private async Task Load()
@@ -412,7 +418,7 @@ else
try
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
@@ -433,7 +439,7 @@ else
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
@@ -79,7 +79,8 @@ else if (_status != null)
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
<LogsSection Status="_status" RefreshIntervalSeconds="_recentLogsIntervalSeconds" LogsMaxLines="_recentLogsMaxLines" LogsAutoScroll="_recentLogsAutoScroll" OnOpenSettings="OpenRecentLogsSettings" />
<AppLogsSection RefreshIntervalSeconds="_appLogsIntervalSeconds" LogsMaxLines="_appLogsMaxLines" LogsAutoScroll="_appLogsAutoScroll" OnOpenSettings="OpenAppLogsSettings" />
</section>
}
@@ -87,6 +88,11 @@ else if (_status != null)
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;
@@ -98,17 +104,27 @@ else if (_status != null)
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync()
protected override void OnInitialized()
{
SettingsModalService.SettingsChanged += OnSettingsChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_loading = true;
try
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_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;
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -133,6 +149,7 @@ else if (_status != null)
finally
{
_loading = false;
StateHasChanged();
}
}
@@ -140,13 +157,20 @@ else if (_status != null)
private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
private void OpenAppLogsSettings() => SettingsModalService.Open(SettingsModalContext.AppLogsPolling, "App logs polling");
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_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();
});
}
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers;
@@ -14,12 +16,14 @@ public class ConfigController : ControllerBase
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
private readonly ILogger<ConfigController> _logger;
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService)
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService, ILogger<ConfigController> logger)
{
_configSetService = configSetService;
_configService = configService;
_reloadService = reloadService;
_logger = logger;
}
/// <summary>Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI.</summary>
@@ -28,11 +32,13 @@ public class ConfigController : ControllerBase
{
try
{
_logger.LogDebug("Get config set");
var set = await _configSetService.GetConfigSetAsync(ct);
return Ok(set);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetSetFailed, ex, "Get config set failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -43,11 +49,13 @@ public class ConfigController : ControllerBase
{
try
{
_logger.LogDebug("Get managed config");
var content = await _configService.ReadManagedConfigAsync(ct);
return Ok(content);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigGetManagedFailed, ex, "Get managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -62,10 +70,12 @@ public class ConfigController : ControllerBase
{
await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ConfigPutManagedSuccess, "Config managed updated, reload success={Success}", reload.Success);
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ConfigPutManagedFailed, ex, "Put managed config failed");
return StatusCode(500, new { error = ex.Message });
}
}
+11 -2
View File
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers;
@@ -11,11 +13,13 @@ public class DhcpController : ControllerBase
{
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
private readonly ILogger<DhcpController> _logger;
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService)
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger<DhcpController> logger)
{
_configService = configService;
_reloadService = reloadService;
_logger = logger;
}
[HttpGet("hosts")]
@@ -23,11 +27,13 @@ public class DhcpController : ControllerBase
{
try
{
_logger.LogDebug("Get DHCP hosts");
var entries = await _configService.ReadDhcpHostsAsync(ct);
return Ok(entries);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpGetHostsFailed, ex, "Get DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -41,14 +47,17 @@ public class DhcpController : ControllerBase
{
await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.DhcpPutHostsSuccess, "DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (ArgumentException ex)
{
_logger.LogWarning(LogEvents.DhcpPutHostsValidationFailed, ex, "DHCP hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.DhcpPutHostsFailed, ex, "Put DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers;
@@ -12,12 +14,14 @@ public class HostsController : ControllerBase
private readonly IHostsFileService _hostsService;
private readonly IReloadService _reloadService;
private readonly IHostsCache _hostsCache;
private readonly ILogger<HostsController> _logger;
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache)
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache, ILogger<HostsController> logger)
{
_hostsService = hostsService;
_reloadService = reloadService;
_hostsCache = hostsCache;
_logger = logger;
}
[HttpGet]
@@ -25,11 +29,13 @@ public class HostsController : ControllerBase
{
try
{
_logger.LogDebug("Get hosts");
var entries = await _hostsService.ReadAsync(ct);
return Ok(entries);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -40,11 +46,13 @@ public class HostsController : ControllerBase
{
try
{
_logger.LogDebug("Get readonly hosts");
var snapshot = await _hostsCache.GetSnapshotAsync(ct);
return Ok(snapshot.ReadOnlyFiles);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsGetReadOnlyFailed, ex, "Get readonly hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -58,14 +66,17 @@ public class HostsController : ControllerBase
{
await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.HostsPutSuccess, "Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(LogEvents.HostsPutValidationFailed, ex, "Hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,6 +1,8 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers;
@@ -10,11 +12,13 @@ public class LeasesController : ControllerBase
{
private readonly ILeasesFileService _leasesService;
private readonly ILeasesCache _cache;
private readonly ILogger<LeasesController> _logger;
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache)
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache, ILogger<LeasesController> logger)
{
_leasesService = leasesService;
_cache = cache;
_logger = logger;
}
[HttpGet]
@@ -22,6 +26,7 @@ public class LeasesController : ControllerBase
{
try
{
_logger.LogDebug("Get leases, refresh={Refresh}", refresh);
if (refresh)
_cache.Invalidate();
var (available, entries) = await _leasesService.TryReadAsync(ct);
@@ -33,6 +38,7 @@ public class LeasesController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Models.Config;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class LoggingController : ControllerBase
{
private readonly IConfiguration _configuration;
private readonly IConfigurationRoot _configurationRoot;
private readonly RuntimeOverridesOptions _overridesOptions;
private readonly ILogger<LoggingController> _logger;
public LoggingController(
IConfiguration configuration,
IOptions<RuntimeOverridesOptions> overridesOptions,
ILogger<LoggingController> logger)
{
_configuration = configuration;
_configurationRoot = (IConfigurationRoot)configuration;
_overridesOptions = overridesOptions.Value;
_logger = logger;
}
/// <summary>Get the current minimum log level from configuration (Logging:LogLevel:Default).</summary>
[HttpGet("level")]
public ActionResult<LogLevelResponse> GetLevel()
{
var level = ParseLevel(_configuration["Logging:LogLevel:Default"]) ?? LogLevel.Information;
return Ok(new LogLevelResponse(LevelName(level)));
}
/// <summary>Set the minimum log level. Writes to appsettings.Overrides.json and reloads config.</summary>
[HttpPost("level")]
public ActionResult<LogLevelResponse> SetLevel([FromBody] LogLevelRequest request)
{
var level = ParseLevel(request?.LogLevel);
if (level == null)
{
_logger.LogWarning(LogEvents.LogLevelSetBadRequest, "Set log level failed: invalid level requested");
return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
}
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null))
{
_logger.LogError(LogEvents.LogLevelSetFailed, "Failed to write log level overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.LogLevelChanged, "Log level changed to {Level}", LevelName(level.Value));
return Ok(new LogLevelResponse(LevelName(level.Value)));
}
/// <summary>Get excluded category prefixes. Prefers runtime overrides (ExcludedCategoryPrefixesOverrides) when present; otherwise uses defaults (ExcludedCategoryPrefixes).</summary>
[HttpGet("filters")]
public ActionResult<FiltersResponse> GetFilters()
{
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
return Ok(new FiltersResponse(prefixes));
}
/// <summary>Set excluded category prefixes. Writes to appsettings.Overrides.json and reloads config.</summary>
[HttpPost("filters")]
public ActionResult<FiltersResponse> SetFilters([FromBody] FiltersRequest request)
{
var prefixes = request?.ExcludedCategoryPrefixes ?? [];
var path = GetOverridesPath();
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to write filters overrides file to {Path}", path);
return StatusCode(500, new { error = "Failed to write overrides file." });
}
_configurationRoot.Reload();
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters updated, count={Count}", prefixes.Count);
return Ok(new FiltersResponse(prefixes));
}
/// <summary>Restore filter defaults by removing overrides. Uses ExcludedCategoryPrefixes from appsettings.json.</summary>
[HttpPost("filters/restore-defaults")]
public ActionResult<FiltersResponse> RestoreFilterDefaults()
{
var path = GetOverridesPath();
if (!TryRemoveFilterOverrides(path))
{
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to remove filter overrides from {Path}", path);
return StatusCode(500, new { error = "Failed to update overrides file." });
}
_configurationRoot.Reload();
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration);
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters restored to defaults, count={Count}", defaults.Count);
return Ok(new FiltersResponse(defaults));
}
private string GetOverridesPath()
{
var path = _overridesOptions.FilePath?.Trim();
return string.IsNullOrEmpty(path)
? Path.Combine(AppContext.BaseDirectory, RuntimeOverridesOptions.DefaultFileName)
: path;
}
private bool TryUpdateOverrides(string filePath, LogLevel? logLevel, List<string>? excludedPrefixes)
{
try
{
var dir = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var root = LoadOverrides(filePath);
if (logLevel.HasValue)
{
root["Logging"] = new JsonObject
{
["LogLevel"] = new JsonObject { ["Default"] = LevelName(logLevel.Value) }
};
}
if (excludedPrefixes != null)
{
// Use sentinel when empty so .NET config returns non-null (empty arrays bind to null)
var values = excludedPrefixes.Count == 0
? new[] { JsonValue.Create(AppLogsConfigHelper.ExplicitlyEmptySentinel) }
: excludedPrefixes.Select(s => JsonValue.Create(s)).ToArray();
root["AppLogs"] = new JsonObject
{
["ExcludedCategoryPrefixesOverrides"] = new JsonArray(values)
};
}
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(filePath, json);
return true;
}
catch
{
return false;
}
}
private bool TryRemoveFilterOverrides(string filePath)
{
try
{
var root = LoadOverrides(filePath);
if (root["AppLogs"] is JsonObject appLogs)
{
appLogs.Remove("ExcludedCategoryPrefixesOverrides");
if (appLogs.Count == 0)
root.Remove("AppLogs");
}
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(filePath, json);
return true;
}
catch
{
return false;
}
}
private static JsonObject LoadOverrides(string filePath)
{
if (!System.IO.File.Exists(filePath))
return new JsonObject();
try
{
var json = System.IO.File.ReadAllText(filePath);
var node = JsonNode.Parse(json);
return node as JsonObject ?? new JsonObject();
}
catch
{
return new JsonObject();
}
}
private static string LevelName(LogLevel level) => level switch
{
LogLevel.Trace => "Trace",
LogLevel.Debug => "Debug",
LogLevel.Information => "Information",
LogLevel.Warning => "Warning",
LogLevel.Error => "Error",
LogLevel.Critical => "Critical",
_ => "Information"
};
private static LogLevel? ParseLevel(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim() switch
{
"Trace" => LogLevel.Trace,
"Debug" => LogLevel.Debug,
"Information" or "Info" => LogLevel.Information,
"Warning" or "Warn" => LogLevel.Warning,
"Error" => LogLevel.Error,
"Critical" => LogLevel.Critical,
_ => null
};
}
public record LogLevelRequest(string? LogLevel);
public record LogLevelResponse(string LogLevel);
public record FiltersRequest(List<string>? ExcludedCategoryPrefixes);
public record FiltersResponse(List<string> ExcludedCategoryPrefixes);
}
@@ -1,5 +1,7 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers;
@@ -8,10 +10,12 @@ namespace DnsmasqWebUI.Controllers;
public class ReloadController : ControllerBase
{
private readonly IReloadService _reloadService;
private readonly ILogger<ReloadController> _logger;
public ReloadController(IReloadService reloadService)
public ReloadController(IReloadService reloadService, ILogger<ReloadController> logger)
{
_reloadService = reloadService;
_logger = logger;
}
[HttpPost]
@@ -20,10 +24,12 @@ public class ReloadController : ControllerBase
try
{
var result = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ReloadRequestSuccess, "Reload requested, success={Success}", result.Success);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Controllers;
@@ -15,15 +17,18 @@ public class StatusController : ControllerBase
private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IProcessRunner _processRunner;
private readonly ILogger<StatusController> _logger;
public StatusController(
IOptions<DnsmasqOptions> options,
IDnsmasqConfigSetService configSetService,
IProcessRunner processRunner)
IProcessRunner processRunner,
ILogger<StatusController> logger)
{
_options = options.Value;
_configSetService = configSetService;
_processRunner = processRunner;
_logger = logger;
}
[HttpGet]
@@ -31,6 +36,7 @@ public class StatusController : ControllerBase
{
try
{
_logger.LogDebug("Get status");
var set = await _configSetService.GetConfigSetAsync(ct);
var effectiveLeasesPath = _configSetService.GetLeasesPath();
var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources();
@@ -98,6 +104,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -140,6 +147,7 @@ public class StatusController : ControllerBase
try
{
_logger.LogDebug("Get status logs download");
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
if (!System.IO.File.Exists(fullPath))
return NotFound();
@@ -154,6 +162,7 @@ public class StatusController : ControllerBase
}
catch (Exception ex)
{
_logger.LogError(LogEvents.StatusLogsDownloadFailed, ex, "Status logs download failed");
return StatusCode(500, new { error = ex.Message });
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
<PackageReference Include="Superpower" Version="3.1.0" />
</ItemGroup>
@@ -0,0 +1,38 @@
using System.Threading.Channels;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Extensions;
/// <summary>
/// App logs setup: buffer, channel, and provider.
/// Encapsulates the wiring that must run before the DI container exists (ILoggerProvider is created during host configuration).
/// Log level and excluded category prefixes are configured via appsettings.Overrides.json (config source with reloadOnChange).
/// </summary>
public static class LoggingBuilderExtensions
{
/// <summary>
/// Adds the app logs provider (forwards ILogger output to SignalR) and registers buffer and channel.
/// Call before <c>builder.Build()</c>.
/// </summary>
public static ILoggingBuilder AddAppLogs(this ILoggingBuilder logging, IConfiguration configuration)
{
var services = logging.Services;
var buffer = new AppLogsBuffer();
var pushChannel = Channel.CreateBounded<byte>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
services.AddSingleton(pushChannel);
services.AddSingleton<IAppLogsBuffer>(buffer);
services.AddSingleton<ILogsService, LogsService>();
logging.AddProvider(new AppLogsLoggerProvider(buffer, pushChannel, configuration));
return logging;
}
}
@@ -30,6 +30,7 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<IHostsClient, HostsClient>(DnsmasqApiClientName);
services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName);
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
services.AddHttpClient<ILoggingClient, LoggingClient>(DnsmasqApiClientName);
return services;
}
@@ -20,7 +20,7 @@ public static class WebApplicationExtensions
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
KnownIPNetworks = { new System.Net.IPNetwork(IPAddress.Loopback, 8) },
});
return app;
+42
View File
@@ -0,0 +1,42 @@
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.SignalR;
namespace DnsmasqWebUI.Hubs;
/// <summary>
/// SignalR hub for log streaming. Clients receive DnsmasqLogsUpdate and AppLogsUpdate.
/// Server push is done via IHubContext; this hub defines methods clients can invoke.
/// </summary>
public class LogsHub : Hub
{
private readonly ILogsService _logsService;
public LogsHub(ILogsService logsService)
{
_logsService = logsService;
}
/// <summary>
/// Client requests dnsmasq logs. Server runs LogsCommand, diffs, and pushes updates.
/// </summary>
public async Task RequestDnsmasqLogs()
{
await _logsService.RunAndPushDnsmasqLogsAsync(Context.ConnectionAborted);
}
/// <summary>
/// Client requests a snapshot of recent app logs (replace). Used on connect.
/// </summary>
public async Task RequestAppLogsSnapshot()
{
await _logsService.PushAppLogsSnapshotAsync(Context.ConnectionAborted);
}
/// <summary>
/// Client polls for new app logs (append). Fallback for reconnect/missed pushes.
/// </summary>
public async Task RequestAppLogsUpdate()
{
await _logsService.PushAppLogsPendingAsync(Context.ConnectionAborted);
}
}
@@ -0,0 +1,11 @@
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
/// <summary>Client for runtime logging API (log level and category filters).</summary>
public interface ILoggingClient
{
Task<string> GetLevelAsync(CancellationToken ct = default);
Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default);
Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default);
Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default);
Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default);
}
@@ -0,0 +1,51 @@
using System.Net.Http.Json;
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Infrastructure.Helpers.Http;
namespace DnsmasqWebUI.Infrastructure.Client;
public sealed class LoggingClient : ILoggingClient
{
private readonly HttpClient _http;
public LoggingClient(HttpClient http) => _http = http;
public async Task<string> GetLevelAsync(CancellationToken ct = default)
{
var response = await _http.GetFromJsonAsync<LogLevelResponse>("api/logging/level", ApiJsonOptions.ClientOptions, ct);
return response?.LogLevel ?? "Information";
}
public async Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default)
{
var response = await _http.PostAsJsonAsync("api/logging/level", new { logLevel }, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<LogLevelResponse>(ApiJsonOptions.ClientOptions, ct);
return body?.LogLevel ?? "Information";
}
public async Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default)
{
var response = await _http.GetFromJsonAsync<FiltersResponse>("api/logging/filters", ApiJsonOptions.ClientOptions, ct);
return response?.ExcludedCategoryPrefixes ?? [];
}
public async Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default)
{
var response = await _http.PostAsJsonAsync("api/logging/filters", new { excludedCategoryPrefixes }, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
return body?.ExcludedCategoryPrefixes ?? [];
}
public async Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default)
{
var response = await _http.PostAsync("api/logging/filters/restore-defaults", null, ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
return body?.ExcludedCategoryPrefixes ?? [];
}
private record LogLevelResponse(string LogLevel);
private record FiltersResponse(List<string> ExcludedCategoryPrefixes);
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Configuration;
namespace DnsmasqWebUI.Infrastructure.Logging;
/// <summary>
/// Helper for resolving app logs config. Uses ExcludedCategoryPrefixesOverrides when present (avoids .NET config array merge);
/// otherwise falls back to ExcludedCategoryPrefixes from appsettings.json.
/// Uses a sentinel value when user explicitly clears all filters, since .NET config returns null for empty arrays.
/// </summary>
public static class AppLogsConfigHelper
{
private const string OverridesKey = "AppLogs:ExcludedCategoryPrefixesOverrides";
private const string DefaultsKey = "AppLogs:ExcludedCategoryPrefixes";
/// <summary>Sentinel value stored when user clears all filters. Filtered out from results; extremely unlikely to match real categories.</summary>
public const string ExplicitlyEmptySentinel = "__AppLogsExplicitlyEmpty__";
/// <summary>Gets effective excluded category prefixes from configuration.</summary>
public static List<string> GetEffectiveExcludedPrefixes(IConfiguration configuration)
{
var overrides = configuration.GetSection(OverridesKey).Get<List<string>>();
if (overrides != null)
return overrides.Where(s => s != ExplicitlyEmptySentinel).ToList();
return GetDefaultExcludedPrefixes(configuration);
}
/// <summary>Gets default excluded category prefixes from appsettings.json (ignores overrides).</summary>
public static List<string> GetDefaultExcludedPrefixes(IConfiguration configuration) =>
configuration.GetSection(DefaultsKey).Get<List<string>>() ?? [];
}
@@ -0,0 +1,89 @@
using System.Threading.Channels;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Logging;
/// <summary>
/// ILoggerProvider that forwards log entries to IAppLogsBuffer for real-time display.
/// Signals the push channel when logs are written so the hosted service can push to clients.
/// Filter is evaluated at Log() time (not CreateLogger) so runtime config changes take effect despite LoggerFactory caching.
/// </summary>
public sealed class AppLogsLoggerProvider : ILoggerProvider
{
private readonly IAppLogsBuffer _buffer;
private readonly Channel<byte> _pushChannel;
private readonly IConfiguration _configuration;
public AppLogsLoggerProvider(
IAppLogsBuffer buffer,
Channel<byte> pushChannel,
IConfiguration configuration)
{
_buffer = buffer;
_pushChannel = pushChannel;
_configuration = configuration;
}
public ILogger CreateLogger(string categoryName) =>
new FilteringAppLogsLogger(categoryName, _buffer, _pushChannel, _configuration);
public void Dispose() { }
private sealed class FilteringAppLogsLogger : ILogger
{
private readonly string _category;
private readonly IAppLogsBuffer _buffer;
private readonly Channel<byte> _pushChannel;
private readonly IConfiguration _configuration;
public FilteringAppLogsLogger(string category, IAppLogsBuffer buffer, Channel<byte> pushChannel, IConfiguration configuration)
{
_category = category;
_buffer = buffer;
_pushChannel = pushChannel;
_configuration = configuration;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (IsExcluded(_category))
return;
var msg = formatter(state, exception);
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
var level = logLevel switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Information => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
_ => "???"
};
var line = $"{timestamp} [{level}] {_category}: {msg}";
if (exception != null)
line += $"{Environment.NewLine}{exception}";
_buffer.Enqueue(line);
_pushChannel.Writer.TryWrite(0);
}
private bool IsExcluded(string category)
{
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
if (prefixes.Count == 0) return false;
foreach (var prefix in prefixes)
{
if (!string.IsNullOrEmpty(prefix) && category.StartsWith(prefix, StringComparison.Ordinal))
return true;
}
return false;
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Logging;
/// <summary>Structured event IDs for application logging. Enables filtering and alerting by event type.</summary>
internal static class LogEvents
{
// Config (1xxx)
public static readonly EventId ConfigGetSetFailed = new(1001, nameof(ConfigGetSetFailed));
public static readonly EventId ConfigGetManagedFailed = new(1002, nameof(ConfigGetManagedFailed));
public static readonly EventId ConfigPutManagedFailed = new(1003, nameof(ConfigPutManagedFailed));
public static readonly EventId ConfigPutManagedSuccess = new(1004, nameof(ConfigPutManagedSuccess));
public static readonly EventId ConfigNoManagedFilePath = new(1005, nameof(ConfigNoManagedFilePath));
public static readonly EventId ConfigWroteManagedFile = new(1006, nameof(ConfigWroteManagedFile));
// Hosts (2xxx)
public static readonly EventId HostsGetFailed = new(2001, nameof(HostsGetFailed));
public static readonly EventId HostsGetReadOnlyFailed = new(2002, nameof(HostsGetReadOnlyFailed));
public static readonly EventId HostsPutFailed = new(2003, nameof(HostsPutFailed));
public static readonly EventId HostsPutValidationFailed = new(2004, nameof(HostsPutValidationFailed));
public static readonly EventId HostsPutSuccess = new(2005, nameof(HostsPutSuccess));
public static readonly EventId HostsWroteManagedFile = new(2006, nameof(HostsWroteManagedFile));
// Dhcp (3xxx)
public static readonly EventId DhcpGetHostsFailed = new(3001, nameof(DhcpGetHostsFailed));
public static readonly EventId DhcpPutHostsFailed = new(3002, nameof(DhcpPutHostsFailed));
public static readonly EventId DhcpPutHostsValidationFailed = new(3003, nameof(DhcpPutHostsValidationFailed));
public static readonly EventId DhcpPutHostsSuccess = new(3004, nameof(DhcpPutHostsSuccess));
// Reload (4xxx)
public static readonly EventId ReloadRequestFailed = new(4001, nameof(ReloadRequestFailed));
public static readonly EventId ReloadRequestSuccess = new(4002, nameof(ReloadRequestSuccess));
public static readonly EventId ReloadCommandNotConfigured = new(4003, nameof(ReloadCommandNotConfigured));
public static readonly EventId ReloadRejectedConcurrent = new(4004, nameof(ReloadRejectedConcurrent));
public static readonly EventId ReloadNonZeroExit = new(4005, nameof(ReloadNonZeroExit));
public static readonly EventId ReloadSucceeded = new(4006, nameof(ReloadSucceeded));
public static readonly EventId ReloadFailed = new(4007, nameof(ReloadFailed));
// Status (5xxx)
public static readonly EventId StatusGetFailed = new(5001, nameof(StatusGetFailed));
public static readonly EventId StatusLogsDownloadFailed = new(5002, nameof(StatusLogsDownloadFailed));
// Leases (6xxx)
public static readonly EventId LeasesGetFailed = new(6001, nameof(LeasesGetFailed));
// Logging (7xxx)
public static readonly EventId LogLevelChanged = new(7001, nameof(LogLevelChanged));
public static readonly EventId LogLevelSetBadRequest = new(7002, nameof(LogLevelSetBadRequest));
public static readonly EventId LogLevelSetFailed = new(7003, nameof(LogLevelSetFailed));
public static readonly EventId FiltersChanged = new(7004, nameof(FiltersChanged));
public static readonly EventId FiltersSetFailed = new(7005, nameof(FiltersSetFailed));
// LogsService (8010)
public static readonly EventId DnsmasqLogsPushFailed = new(8010, nameof(DnsmasqLogsPushFailed));
// ProcessRunner (8xxx)
public static readonly EventId CommandFailed = new(8001, nameof(CommandFailed));
public static readonly EventId CommandTimeout = new(8002, nameof(CommandTimeout));
public static readonly EventId CommandStarted = new(8003, nameof(CommandStarted));
public static readonly EventId CommandCompleted = new(8004, nameof(CommandCompleted));
// LeasesFileService (9xxx)
public static readonly EventId LeasesReadSuccess = new(9001, nameof(LeasesReadSuccess));
// Application lifecycle (9xxx)
public static readonly EventId ApplicationStarted = new(9101, nameof(ApplicationStarted));
public static readonly EventId ApplicationStopping = new(9102, nameof(ApplicationStopping));
public static readonly EventId ManagedConfigConfFileLineSet = new(9110, nameof(ManagedConfigConfFileLineSet));
public static readonly EventId ManagedConfigCreatedAtStartup = new(9111, nameof(ManagedConfigCreatedAtStartup));
}
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Ring buffer of recent app log lines. Filled by ILoggerProvider, drained for SignalR push.
/// </summary>
public interface IAppLogsBuffer
{
/// <summary>Returns up to maxLines most recent lines (oldest first).</summary>
IReadOnlyList<string> GetRecent(int maxLines = 500);
/// <summary>Enqueues a formatted log line. Called by the logging provider.</summary>
void Enqueue(string line);
/// <summary>Returns and clears pending lines for streaming push. Called by pusher.</summary>
IReadOnlyList<string> DrainPending();
}
@@ -0,0 +1,24 @@
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Runs LogsCommand, diffs output, chunks by lines, and pushes via SignalR.
/// Triggered by client (RequestDnsmasqLogs) or file watcher when LogsPath changes.
/// App logs: event-driven push when provider writes; snapshot on connect; poll fallback.
/// </summary>
public interface ILogsService
{
/// <summary>
/// Runs LogsCommand, diffs against cache, and pushes DnsmasqLogsUpdate to all connected clients.
/// </summary>
Task RunAndPushDnsmasqLogsAsync(CancellationToken ct = default);
/// <summary>
/// Pushes recent app logs from buffer to all clients (replace). Called when client connects.
/// </summary>
Task PushAppLogsSnapshotAsync(CancellationToken ct = default);
/// <summary>
/// Pushes pending app log lines as append to all clients. Event-driven from provider; poll fallback.
/// </summary>
Task PushAppLogsPendingAsync(CancellationToken ct = default);
}
@@ -7,4 +7,7 @@ public interface IProcessRunner : IApplicationScopedService
{
/// <summary>Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out.</summary>
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default);
/// <summary>Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice.</summary>
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default);
}
@@ -0,0 +1,43 @@
namespace DnsmasqWebUI.Infrastructure.Services;
/// <inheritdoc cref="Abstractions.IAppLogsBuffer" />
public sealed class AppLogsBuffer : Abstractions.IAppLogsBuffer
{
private const int MaxLines = 1000;
private readonly List<string> _lines = [];
private readonly List<string> _pending = [];
private readonly object _lock = new();
public IReadOnlyList<string> GetRecent(int maxLines = 500)
{
lock (_lock)
{
if (_lines.Count <= maxLines)
return _lines.ToList();
return _lines.TakeLast(maxLines).ToList();
}
}
public void Enqueue(string line)
{
lock (_lock)
{
_lines.Add(line);
_pending.Add(line);
while (_lines.Count > MaxLines)
_lines.RemoveAt(0);
}
}
public IReadOnlyList<string> DrainPending()
{
lock (_lock)
{
if (_pending.Count == 0)
return [];
var copy = _pending.ToList();
_pending.Clear();
return copy;
}
}
}
@@ -0,0 +1,65 @@
using System.Threading.Channels;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>
/// Listens for signals from AppLogsLoggerProvider; drains pending app logs and pushes via LogsService.
/// Event-driven: push happens when logs are written, with a short debounce to batch rapid writes.
/// </summary>
public sealed class AppLogsPushTriggeredHostedService : IApplicationHostedService
{
private const int DebounceMs = 50;
private readonly Channel<byte> _channel;
private readonly ILogsService _logsService;
private readonly ILogger<AppLogsPushTriggeredHostedService> _logger;
public AppLogsPushTriggeredHostedService(
Channel<byte> appLogsPushChannel,
ILogsService logsService,
ILogger<AppLogsPushTriggeredHostedService> logger)
{
_channel = appLogsPushChannel;
_logsService = logsService;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
_ = RunLoopAsync(ct);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
private async Task RunLoopAsync(CancellationToken ct)
{
try
{
await foreach (var _ in _channel.Reader.ReadAllAsync(ct))
{
await Task.Delay(DebounceMs, ct);
while (_channel.Reader.TryRead(out byte discarded)) { } // Drain extra signals during debounce
try
{
await _logsService.PushAppLogsPendingAsync(ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error pushing app logs");
}
}
}
catch (OperationCanceledException)
{
// Shutdown; ignore
}
}
}
@@ -0,0 +1,29 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>Logs application started and stopping so the app logs viewer shows lifecycle events.</summary>
public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
{
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger;
public ApplicationLifecycleLoggingHostedService(ILogger<ApplicationLifecycleLoggingHostedService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStarted, "Application started");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation(LogEvents.ApplicationStopping, "Application stopping");
return Task.CompletedTask;
}
}
@@ -12,6 +12,7 @@ namespace DnsmasqWebUI.Infrastructure.Services;
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<ClientSettingsService> _logger;
private IJSObjectReference? _module;
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -20,13 +21,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
WriteIndented = false
};
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
public ClientSettingsService(IJSRuntime jsRuntime, ILogger<ClientSettingsService> logger)
{
_jsRuntime = jsRuntime;
_logger = logger;
}
private async Task<IJSObjectReference> GetModuleAsync()
private async Task<IJSObjectReference?> GetModuleAsync()
{
if (_module != null) return _module;
try
{
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/client-settings-storage.js");
}
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS import skipped: prerender (no JS available)"); return null; }
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS import skipped: circuit disconnected"); return null; }
catch (JSException ex) { _logger.LogDebug(ex, "JS import failed"); return null; }
return _module;
}
@@ -34,6 +45,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
public async Task<ClientSettings> LoadSettingsAsync()
{
var module = await GetModuleAsync();
if (module == null) return new ClientSettings();
var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
try
@@ -49,8 +61,9 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
/// <inheritdoc />
public async Task SaveSettingsAsync(ClientSettings settings)
{
var json = JsonSerializer.Serialize(settings, JsonOptions);
var module = await GetModuleAsync();
if (module == null) return;
var json = JsonSerializer.Serialize(settings, JsonOptions);
await module.InvokeVoidAsync("setItem", json);
}
@@ -60,13 +73,10 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
public async ValueTask DisposeAsync()
{
if (_module == null) return;
try
{
await _module.DisposeAsync();
}
finally
{
_module = null;
}
try { await _module.DisposeAsync(); }
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped (prerender)"); }
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped: circuit disconnected"); }
catch (JSException ex) { _logger.LogDebug(ex, "JS module DisposeAsync failed"); }
finally { _module = null; }
}
}
@@ -1,12 +1,14 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
@@ -34,7 +36,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
{
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
_logger.LogDebug(LogEvents.ConfigNoManagedFilePath, "No managed file path (no conf-dir in main config); returning empty dhcp hosts");
return Array.Empty<DhcpHostEntry>();
}
var allEntries = snapshot.DhcpHostEntries.ToList();
@@ -149,7 +151,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
_logger.LogInformation("Wrote managed config file: {Path}", path);
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
}
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
@@ -204,7 +206,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
_logger.LogInformation("Wrote managed config file: {Path}", path);
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
}
}
@@ -1,9 +1,11 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
@@ -79,7 +81,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
_logger.LogInformation(LogEvents.ManagedConfigConfFileLineSet, "Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
}
else
{
@@ -91,7 +93,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
if (File.Exists(set.ManagedFilePath))
return;
_logger.LogInformation("Creating managed config file at startup: {Path}", set.ManagedFilePath);
_logger.LogInformation(LogEvents.ManagedConfigCreatedAtStartup, "Creating managed config file at startup: {Path}", set.ManagedFilePath);
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
}
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
@@ -39,6 +41,6 @@ public class HostsFileService : IHostsFileService
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries);
_logger.LogInformation("Wrote managed hosts file: {Path}", path);
_logger.LogInformation(LogEvents.HostsWroteManagedFile, "Wrote managed hosts file: {Path}", path);
}
}
@@ -1,15 +1,19 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
public class LeasesFileService : ILeasesFileService
{
private readonly ILeasesCache _cache;
private readonly ILogger<LeasesFileService> _logger;
public LeasesFileService(ILeasesCache cache)
public LeasesFileService(ILeasesCache cache, ILogger<LeasesFileService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default)
@@ -19,5 +23,10 @@ public class LeasesFileService : ILeasesFileService
}
public async Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> TryReadAsync(CancellationToken ct = default)
=> await _cache.GetOrRefreshAsync(ct);
{
var (available, entries) = await _cache.GetOrRefreshAsync(ct);
if (available && entries != null)
_logger.LogInformation(LogEvents.LeasesReadSuccess, "Leases read, count={Count}", entries.Count);
return (available, entries);
}
}
@@ -0,0 +1,141 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>
/// When LogsPath is set in effective config, watches that file and triggers LogsService push on change.
/// Polling remains the primary mechanism; this is a best-effort optimization.
/// </summary>
public sealed class LogsFileWatcherHostedService : IApplicationHostedService
{
private const int ResolveIntervalSeconds = 60;
private readonly IDnsmasqConfigSetService _configSetService;
private readonly ILogsService _logsService;
private readonly ILogger<LogsFileWatcherHostedService> _logger;
private FileSystemWatcher? _watcher;
private string? _currentPath;
private readonly object _lock = new();
public LogsFileWatcherHostedService(
IDnsmasqConfigSetService configSetService,
ILogsService logsService,
ILogger<LogsFileWatcherHostedService> logger)
{
_configSetService = configSetService;
_logsService = logsService;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
_ = RunLoopAsync(ct);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
private async Task RunLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var (effectiveConfig, _) = _configSetService.GetEffectiveConfigWithSources();
var logsPath = EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig);
UpdateWatcher(logsPath);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error resolving LogsPath for file watcher");
}
try
{
await Task.Delay(TimeSpan.FromSeconds(ResolveIntervalSeconds), ct);
}
catch (OperationCanceledException)
{
break;
}
}
lock (_lock)
{
_watcher?.Dispose();
_watcher = null;
_currentPath = null;
}
}
private void UpdateWatcher(string? logsPath)
{
var normalized = string.IsNullOrEmpty(logsPath) ? null : Path.GetFullPath(logsPath.Trim());
lock (_lock)
{
if (string.Equals(_currentPath, normalized, StringComparison.Ordinal))
return;
_watcher?.Dispose();
_watcher = null;
_currentPath = normalized;
if (string.IsNullOrEmpty(normalized))
{
_logger.LogDebug("Logs path not set; no file watcher");
return;
}
var dir = Path.GetDirectoryName(normalized);
var fileName = Path.GetFileName(normalized);
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName))
{
_logger.LogDebug("Invalid logs path for watcher: {Path}", normalized);
return;
}
try
{
if (!Directory.Exists(dir))
{
_logger.LogDebug("Logs directory does not exist yet: {Dir}", dir);
return;
}
_watcher = new FileSystemWatcher(dir)
{
Filter = fileName,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
};
_watcher.Changed += OnFileChanged;
_watcher.EnableRaisingEvents = true;
_logger.LogDebug("Watching logs file: {Path}", normalized);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create file watcher for logs: {Path}", normalized);
}
}
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
_ = InvokePushAsync();
}
private async Task InvokePushAsync()
{
try
{
await _logsService.RunAndPushDnsmasqLogsAsync(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error pushing logs on file change");
}
}
}
@@ -0,0 +1,202 @@
using System.Text;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>
/// Runs LogsCommand, diffs against cache, chunks by lines, and pushes via SignalR.
/// </summary>
public sealed class LogsService : ILogsService
{
/// <summary>Max chars from command output before truncation.</summary>
private const int MaxCommandOutputChars = 512 * 1024;
/// <summary>Max bytes per SignalR message (under 32KB default).</summary>
private const int MaxMessageContentBytes = 28 * 1024;
private readonly IHubContext<Hubs.LogsHub> _hubContext;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<DnsmasqOptions> _options;
private readonly IAppLogsBuffer _appLogsBuffer;
private readonly ILogger<LogsService> _logger;
private readonly object _dnsmasqLock = new();
private string _lastDnsmasqOutput = "";
public LogsService(
IHubContext<Hubs.LogsHub> hubContext,
IServiceScopeFactory scopeFactory,
IOptions<DnsmasqOptions> options,
IAppLogsBuffer appLogsBuffer,
ILogger<LogsService> logger)
{
_hubContext = hubContext;
_scopeFactory = scopeFactory;
_options = options;
_appLogsBuffer = appLogsBuffer;
_logger = logger;
}
public async Task RunAndPushDnsmasqLogsAsync(CancellationToken ct = default)
{
var cmd = _options.Value.LogsCommand?.Trim();
if (string.IsNullOrEmpty(cmd))
return;
try
{
using var scope = _scopeFactory.CreateScope();
var processRunner = scope.ServiceProvider.GetRequiredService<IProcessRunner>();
var result = await processRunner.RunAsync(cmd, TimeSpan.FromSeconds(10), MaxCommandOutputChars, ct);
var raw = result.Stdout + (result.TimedOut ? "\n(Command timed out.)" : "");
string mode;
string content;
lock (_dnsmasqLock)
{
var (m, c) = ComputeDelta(_lastDnsmasqOutput, raw);
_lastDnsmasqOutput = raw;
mode = m;
content = c;
}
if (string.IsNullOrEmpty(content) && mode == "append")
return;
await PushChunkedAsync("DnsmasqLogsUpdate", mode, content, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(LogEvents.DnsmasqLogsPushFailed, ex, "Failed to run or push dnsmasq logs");
await PushChunkedAsync("DnsmasqLogsUpdate", "replace", $"(Error: {ex.Message})\n", ct);
}
}
public async Task PushAppLogsSnapshotAsync(CancellationToken ct = default)
{
var lines = _appLogsBuffer.GetRecent(maxLines: 1000);
if (lines.Count == 0)
return;
var content = string.Join("\n", lines);
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
content += "\n";
await PushChunkedAsync("AppLogsUpdate", "replace", content, ct);
}
public async Task PushAppLogsPendingAsync(CancellationToken ct = default)
{
var lines = _appLogsBuffer.DrainPending();
if (lines.Count == 0)
return;
var content = string.Join("\n", lines);
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
content += "\n";
await PushChunkedAsync("AppLogsUpdate", "append", content, ct);
}
private static (string mode, string content) ComputeDelta(string oldOutput, string newOutput)
{
if (string.IsNullOrEmpty(oldOutput))
return ("replace", newOutput);
var oldLines = oldOutput.Split('\n');
var newLines = newOutput.Split('\n');
// Find longest suffix of old that matches prefix of new (tail-style overlap)
var maxOverlap = Math.Min(oldLines.Length, newLines.Length);
var overlap = 0;
for (var k = 1; k <= maxOverlap; k++)
{
var match = true;
for (var i = 0; i < k && match; i++)
{
if (oldLines[oldLines.Length - k + i] != newLines[i])
match = false;
}
if (match)
overlap = k;
}
if (overlap == 0 || overlap >= newLines.Length)
return ("replace", newOutput);
var newOnly = newLines[overlap..];
var content = string.Join("\n", newOnly);
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
content += "\n";
return ("append", content);
}
private async Task PushChunkedAsync(string method, string mode, string content, CancellationToken ct)
{
var chunks = ChunkByLines(content, MaxMessageContentBytes);
for (var i = 0; i < chunks.Count; i++)
{
var payload = new LogsUpdatePayload(i == 0 ? mode : "append", chunks[i]);
await _hubContext.Clients.All.SendAsync(method, payload, ct);
}
}
private static List<string> ChunkByLines(string content, int maxBytesPerChunk)
{
if (string.IsNullOrEmpty(content))
return [];
var lines = content.Split('\n');
var chunks = new List<string>();
var current = new StringBuilder();
var enc = Encoding.UTF8;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var lineWithNewline = i < lines.Length - 1 ? line + "\n" : (line.Length > 0 ? line + "\n" : line);
var lineBytes = enc.GetByteCount(lineWithNewline);
if (lineBytes > maxBytesPerChunk)
{
if (current.Length > 0)
{
chunks.Add(current.ToString());
current.Clear();
}
var trunc = line.Length;
while (trunc > 0 && enc.GetByteCount(line[..trunc] + "... (line truncated)\n") > maxBytesPerChunk)
trunc--;
chunks.Add((trunc > 0 ? line[..trunc] : line) + "... (line truncated)\n");
continue;
}
if (current.Length > 0 && enc.GetByteCount(current.ToString()) + lineBytes > maxBytesPerChunk)
{
chunks.Add(current.ToString());
current.Clear();
}
if (i < lines.Length - 1)
current.Append(line).Append('\n');
else if (line.Length > 0)
current.Append(line).Append('\n');
}
if (current.Length > 0)
chunks.Add(current.ToString());
return chunks;
}
}
@@ -1,7 +1,8 @@
using System.Diagnostics;
using System.Text;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services;
@@ -9,15 +10,24 @@ namespace DnsmasqWebUI.Infrastructure.Services;
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
public sealed class ProcessRunner : IProcessRunner
{
private const int MaxCommandPrefixLength = 80;
private readonly ILogger<ProcessRunner> _logger;
public ProcessRunner(ILogger<ProcessRunner> logger) => _logger = logger;
public async Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default)
public Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default) =>
RunAsync(command, timeout, null, ct);
public async Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(command))
return new ProcessRunResult(null, "", "", false);
var trimmed = command!.Trim();
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
_logger.LogDebug(LogEvents.CommandStarted, "Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
var stdout = new StringBuilder();
var stderr = new StringBuilder();
using var process = new Process
@@ -33,8 +43,31 @@ public sealed class ProcessRunner : IProcessRunner
}
};
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
var truncMsg = "\n\n(output truncated)\n";
process.OutputDataReceived += (_, e) =>
{
if (e.Data == null) return;
if (maxOutputChars.HasValue && stdout.Length >= maxOutputChars.Value) return;
stdout.AppendLine(e.Data);
if (maxOutputChars.HasValue && stdout.Length > maxOutputChars.Value)
{
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
stdout.Length = keep;
stdout.Append(truncMsg);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data == null) return;
if (maxOutputChars.HasValue && stderr.Length >= maxOutputChars.Value) return;
stderr.AppendLine(e.Data);
if (maxOutputChars.HasValue && stderr.Length > maxOutputChars.Value)
{
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
stderr.Length = keep;
stderr.Append(truncMsg);
}
};
try
{
@@ -50,7 +83,7 @@ public sealed class ProcessRunner : IProcessRunner
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
{
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
_logger.LogWarning(LogEvents.CommandTimeout, "Command timed out after {Timeout}s", timeout.TotalSeconds);
try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n";
@@ -58,15 +91,17 @@ public sealed class ProcessRunner : IProcessRunner
return new ProcessRunResult(null, stdout.ToString(), err, true);
}
var exitCode = process.HasExited ? process.ExitCode : -1;
_logger.LogDebug(LogEvents.CommandCompleted, "Command completed, exit code={ExitCode}", exitCode);
return new ProcessRunResult(
process.HasExited ? process.ExitCode : -1,
exitCode,
stdout.ToString(),
stderr.ToString(),
false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to run command");
_logger.LogError(LogEvents.CommandFailed, ex, "Failed to run command");
return new ProcessRunResult(null, "", "", false, ex.Message);
}
}
@@ -1,5 +1,7 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services;
@@ -22,13 +24,13 @@ public class ReloadService : IReloadService
{
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
{
_logger.LogDebug("Reload command not configured");
_logger.LogDebug(LogEvents.ReloadCommandNotConfigured, "Reload command not configured");
return new ReloadResult(true, 0, null, "Reload command not configured");
}
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct))
{
_logger.LogDebug("Reload already in progress, rejecting concurrent request");
_logger.LogDebug(LogEvents.ReloadRejectedConcurrent, "Reload already in progress, rejecting concurrent request");
return new ReloadResult(false, -1, null, "Reload already in progress.");
}
@@ -42,9 +44,9 @@ public class ReloadService : IReloadService
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue)
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
_logger.LogWarning(LogEvents.ReloadNonZeroExit, "Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
else if (result.ExitCode == 0)
_logger.LogInformation("Reload command succeeded");
_logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded");
return new ReloadResult(
result.ExitCode == 0,
@@ -54,7 +56,7 @@ public class ReloadService : IReloadService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to run reload command");
_logger.LogError(LogEvents.ReloadFailed, ex, "Failed to run reload command");
return new ReloadResult(false, -1, null, ex.Message);
}
finally
@@ -8,9 +8,24 @@ public class ClientSettings
/// <summary>Polling interval for the service status block (seconds).</summary>
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
/// <summary>Polling interval for the recent logs block (seconds).</summary>
/// <summary>Polling interval for the recent dnsmasq logs block (seconds).</summary>
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
/// <summary>Polling interval for the app logs block fallback (seconds).</summary>
public int AppLogsPollingIntervalSeconds { get; set; } = 5;
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
public int LeasesPollingIntervalSeconds { get; set; } = 5;
/// <summary>Maximum number of lines in the dnsmasq (recent) logs panel. Older lines are truncated.</summary>
public int RecentLogsMaxLines { get; set; } = 500;
/// <summary>Whether to auto-scroll the dnsmasq logs panel when new lines arrive.</summary>
public bool RecentLogsAutoScroll { get; set; } = true;
/// <summary>Maximum number of lines in the app logs panel. Older lines are truncated.</summary>
public int AppLogsMaxLines { get; set; } = 500;
/// <summary>Whether to auto-scroll the app logs panel when new lines arrive.</summary>
public bool AppLogsAutoScroll { get; set; } = true;
}
@@ -0,0 +1,23 @@
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).
/// </summary>
public static class ClientSettingsFields
{
public sealed record FieldBounds(int Min, int Max, string DisplayName)
{
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}.";
}
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");
}
@@ -11,9 +11,12 @@ public enum SettingsModalContext
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
/// <summary>Show only recent dnsmasq logs polling interval.</summary>
LogsPolling,
/// <summary>Show only app logs polling interval.</summary>
AppLogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
@@ -25,7 +28,10 @@ public static class SettingsModalSections
{
public const string ServiceStatus = "ServiceStatus";
public const string Logs = "Logs";
public const string AppLogs = "AppLogs";
public const string Leases = "Leases";
public const string RecentLogsDisplay = "RecentLogsDisplay";
public const string AppLogsDisplay = "AppLogsDisplay";
/// <summary>Section metadata: display name (header), field label (for input), and searchable text for filtering.</summary>
public sealed record SectionMeta(string DisplayName, string FieldLabel, string SearchableText);
@@ -33,19 +39,30 @@ public static class SettingsModalSections
public static readonly IReadOnlyDictionary<string, SectionMeta> All = new Dictionary<string, SectionMeta>(StringComparer.OrdinalIgnoreCase)
{
[ServiceStatus] = new("Service status", "Polling interval (seconds)", "service status polling interval seconds"),
[Logs] = new("Recent logs", "Polling interval (seconds)", "recent logs polling interval seconds"),
[Logs] = new("Recent logs", "Polling interval (seconds)", "recent dnsmasq logs polling interval seconds"),
[AppLogs] = new("App logs", "Polling interval (seconds)", "app logs polling interval seconds"),
[Leases] = new("DHCP leases", "Refresh interval (seconds)", "dhcp leases refresh interval seconds"),
[RecentLogsDisplay] = new("Recent logs display", "Max lines / Auto-scroll", "recent logs display max lines auto scroll"),
[AppLogsDisplay] = new("App logs display", "Max lines / Auto-scroll", "app logs display max lines auto scroll"),
};
/// <summary>Maps a focused context (single-section view) to its section key. Returns null for All.</summary>
public static string? GetSectionKeyForContext(SettingsModalContext context) => context switch
/// <summary>Maps a focused context (single-section view) to its section keys. Returns null for All (search mode).</summary>
public static string[]? GetSectionKeysForContext(SettingsModalContext context) => context switch
{
SettingsModalContext.ServicePolling => ServiceStatus,
SettingsModalContext.LogsPolling => Logs,
SettingsModalContext.LeasesPolling => Leases,
SettingsModalContext.ServicePolling => [ServiceStatus],
SettingsModalContext.LogsPolling => [Logs, RecentLogsDisplay],
SettingsModalContext.AppLogsPolling => [AppLogs, AppLogsDisplay],
SettingsModalContext.LeasesPolling => [Leases],
_ => null
};
/// <summary>Legacy: returns first section key for context. Use GetSectionKeysForContext for multi-section support.</summary>
public static string? GetSectionKeyForContext(SettingsModalContext context)
{
var keys = GetSectionKeysForContext(context);
return keys is { Length: > 0 } ? keys[0] : null;
}
public static bool MatchesSearch(string key, string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm)) return true;
@@ -0,0 +1,27 @@
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Configuration for the app logs UI: which logger categories to exclude from the live display.
/// Bound from "AppLogs" config section (appsettings.json, appsettings.Overrides.json, or AppLogs__* env vars).
/// </summary>
public class AppLogsOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "AppLogs";
/// <summary>
/// Logger category name prefixes to exclude from the app logs display.
/// Any category that starts with one of these strings will not be forwarded to the buffer.
/// Defaults exclude noisy framework categories: Components, Kestrel, HttpClient, etc.
/// </summary>
public List<string> ExcludedCategoryPrefixes { get; set; } =
[
"Microsoft.AspNetCore.Components.",
"Microsoft.AspNetCore.Server.Kestrel.Transport.",
"Microsoft.AspNetCore.Server.Kestrel.Connections",
"System.Net.Http.",
"Microsoft.Extensions.Http.",
"Microsoft.AspNetCore.Hosting.",
"Microsoft.AspNetCore.Routing.",
];
}
@@ -0,0 +1,19 @@
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Path for the app settings overrides file used to persist runtime config changes
/// (e.g. log level). Bound from "RuntimeOverrides" config section.
/// </summary>
public class RuntimeOverridesOptions
{
public const string SectionName = "RuntimeOverrides";
/// <summary>Default filename when FilePath is not set (resolved relative to application base directory).</summary>
public const string DefaultFileName = "appsettings.Overrides.json";
/// <summary>
/// Full path to the JSON file the app reads/writes for runtime overrides.
/// If not set, defaults to <see cref="DefaultFileName"/> in the application base directory.
/// </summary>
public string? FilePath { get; set; }
}
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Models.Logs;
/// <summary>
/// Payload sent via SignalR for log updates. Content is chunked by complete lines, max ~28KB per message.
/// </summary>
public sealed record LogsUpdatePayload(string Mode, string Content)
{
/// <summary>"append" = add to existing; "replace" = clear then set.</summary>
public string Mode { get; } = Mode;
/// <summary>One or more complete lines. Never split mid-line.</summary>
public string Content { get; } = Content;
}
+14
View File
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Components;
using DnsmasqWebUI.Hubs;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Helpers.Http;
@@ -12,9 +13,19 @@ var builder = isDevelopment
? WebApplication.CreateBuilder(args)
: WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), Args = args });
// Runtime overrides file (Logging.LogLevel.Default, etc.) — add as config source so startup uses actual log level.
var overridesPath = builder.Configuration["RuntimeOverrides:FilePath"]?.Trim();
if (string.IsNullOrEmpty(overridesPath))
overridesPath = Path.Combine(AppContext.BaseDirectory, RuntimeOverridesOptions.DefaultFileName);
builder.Configuration.AddJsonFile(overridesPath, optional: true, reloadOnChange: true);
// ---- Application options (title, etc.) ----
builder.Services.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName));
builder.Services.AddOptions<AppLogsOptions>()
.Bind(builder.Configuration.GetSection(AppLogsOptions.SectionName));
builder.Services.AddOptions<RuntimeOverridesOptions>()
.Bind(builder.Configuration.GetSection(RuntimeOverridesOptions.SectionName));
// ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>()
@@ -30,9 +41,11 @@ builder.Services.AddDnsmasqApiHttpClients();
builder.Services.AddControllers()
.AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions));
builder.Services.AddSignalR();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Logging.AddAppLogs(builder.Configuration);
builder.Services.AddCorsFromConfiguration(builder.Configuration);
var app = builder.Build();
@@ -59,6 +72,7 @@ app.UseAntiforgery();
// ---- Endpoints ----
app.MapControllers();
app.MapHub<LogsHub>("/hubs/logs");
// UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly.
app.UseStaticFiles();
app.MapRazorComponents<App>()
+14 -1
View File
@@ -5,9 +5,22 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System.Net.Http.HttpClient": "Warning"
}
},
"AppLogs": {
"ExcludedCategoryPrefixes": [
"Microsoft.AspNetCore.Components.",
"Microsoft.AspNetCore.Server.Kestrel.Transport.",
"Microsoft.AspNetCore.Server.Kestrel.Connections",
"System.Net.Http.",
"Microsoft.Extensions.Http.",
"Microsoft.AspNetCore.Hosting.",
"Microsoft.AspNetCore.Routing."
]
},
"AllowedHosts": "*",
"ForwardedHeaders": {
"Enabled": false
+37
View File
@@ -0,0 +1,37 @@
/**
* Logs terminal: append/replace content with optional max lines and auto-scroll. Used by LogsSection for dnsmasq and app logs.
* @param {string} elementId - id of the pre element
* @param {string} content - log content to append or replace
* @param {{ maxLines?: number, autoScroll?: boolean }} [options] - maxLines: truncate to last N lines; autoScroll: scroll to bottom (default true)
*/
export function appendLogs(elementId, content, options = {}) {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent += content;
truncateToMaxLines(el, options.maxLines);
if (options.autoScroll !== false)
scrollLogsToBottom(elementId);
}
export function replaceLogs(elementId, content, options = {}) {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent = content;
truncateToMaxLines(el, options.maxLines);
if (options.autoScroll !== false)
scrollLogsToBottom(elementId);
}
function truncateToMaxLines(el, maxLines) {
if (maxLines == null || maxLines <= 0) return;
const text = el.textContent || '';
const lines = text.split('\n');
if (lines.length <= maxLines) return;
el.textContent = lines.slice(-maxLines).join('\n');
}
export function scrollLogsToBottom(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
el.scrollTop = el.scrollHeight;
}