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: | run: |
docker run --rm \ docker run --rm \
-v "${{ github.workspace }}:/src" -w /src \ -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 }} ./scripts/publish-self-contained.sh ${{ matrix.rid }}
- name: Setup .NET - name: Setup .NET
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '9.0.x' dotnet-version: '10.0.x'
- name: Publish for ${{ matrix.rid }} (musl on host) - name: Publish for ${{ matrix.rid }} (musl on host)
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
@@ -44,7 +44,7 @@ jobs:
- name: Zip publish output - name: Zip publish output
run: | 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" .) (cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
- name: Upload artifact - name: Upload artifact
+2 -1
View File
@@ -43,6 +43,7 @@ testdata-mount/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# User-specific # User-specific / runtime
appsettings.*.local.json appsettings.*.local.json
appsettings.Overrides.json
*.local *.local
+2 -2
View File
@@ -3,12 +3,12 @@
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh) # 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. # 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 WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"] COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj" 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 ./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) # 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__StatusShowCommand: "/app/dnsmasq-status.sh"
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'" 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: volumes:
- ${TESTDATA_MOUNT:-./testdata-mount}:/data - ${TESTDATA_MOUNT:-./testdata-mount}:/data
networks: networks:
+2 -2
View File
@@ -346,7 +346,7 @@ do_install_from_source() {
exit 1 exit 1
fi fi
if ! command -v dotnet >/dev/null 2>&1; then 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 exit 1
fi fi
@@ -371,7 +371,7 @@ do_install_from_source() {
echo "Error: Could not determine RID from build output." >&2 echo "Error: Could not determine RID from build output." >&2
exit 1 exit 1
fi 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 if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
echo "Error: Build output not found at $publish_dir" >&2 echo "Error: Build output not found at $publish_dir" >&2
exit 1 exit 1
+15 -16
View File
@@ -3,7 +3,7 @@
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml. # (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
# #
# What this script does: # 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]. # then docker compose up -d [--build] [--force-recreate].
# --stop: docker compose down (stop and remove containers/networks). # --stop: docker compose down (stop and remove containers/networks).
# --tidy: docker compose down, then clear the mount directory for a clean next run. # --tidy: docker compose down, then clear the mount directory for a clean next run.
@@ -18,7 +18,7 @@ MOUNT_DIR=""
PREPARE_ONLY=false PREPARE_ONLY=false
BUILD=false BUILD=false
RECREATE=false RECREATE=false
NO_CLEAR=false CLEAR=false
STOP=false STOP=false
TIDY=false TIDY=false
@@ -26,10 +26,10 @@ usage() {
echo "Usage: $0 [OPTIONS] [--]" echo "Usage: $0 [OPTIONS] [--]"
echo "" echo ""
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness" 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 ""
echo "Steps (when starting):" 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 " 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 " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
echo "" echo ""
@@ -39,9 +39,8 @@ usage() {
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount." echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
echo "" echo ""
echo "Mount behaviour:" echo "Mount behaviour:"
echo " (default) Clear mount dir completely, then sync. Use for a clean run." echo " (default) Preserve mount dir; sync source over existing contents."
echo " --no-clear Do not clear mount dir; only sync over existing contents." echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo " Use to preserve leases or debug files between runs."
echo "" echo ""
echo "Compose behaviour:" echo "Compose behaviour:"
echo " --prepare-only Only prepare the mount; do not run docker compose." echo " --prepare-only Only prepare the mount; do not run docker compose."
@@ -62,19 +61,19 @@ usage() {
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0" 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 ""
echo " $0 --build" echo " $0 --build"
echo " Clear and sync, then rebuild images and start containers." echo " Sync, rebuild images, and start containers."
echo "" echo ""
echo " $0 --recreate" 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 ""
echo " $0 --no-clear" echo " $0 --clear"
echo " Preserve mount contents, sync over it, start (no rebuild)." echo " Clear mount, sync testdata, start (clean run, no rebuild)."
echo "" echo ""
echo " $0 --prepare-only" 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 " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
echo "" echo ""
echo " $0 --source myfixtures --mount mymount --prepare-only" echo " $0 --source myfixtures --mount mymount --prepare-only"
@@ -106,8 +105,8 @@ while [ $# -gt 0 ]; do
MOUNT_DIR="$1" MOUNT_DIR="$1"
shift shift
;; ;;
--no-clear) --clear)
NO_CLEAR=true CLEAR=true
shift shift
;; ;;
--prepare-only) --prepare-only)
@@ -189,7 +188,7 @@ docker compose -f "$COMPOSE_FILE" down
mkdir -p "$MOUNT_DIR" mkdir -p "$MOUNT_DIR"
if [ "$NO_CLEAR" = false ]; then if [ "$CLEAR" = true ]; then
echo "Clearing mount directory: $MOUNT_DIR" echo "Clearing mount directory: $MOUNT_DIR"
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
fi fi
+2 -2
View File
@@ -85,7 +85,7 @@ while [ $# -gt 0 ]; do
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64" echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64" echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
echo "" echo ""
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/" echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0 # Publish for current machine (auto-detect RID)" echo " $0 # Publish for current machine (auto-detect RID)"
@@ -163,7 +163,7 @@ else
--self-contained true --self-contained true
fi 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 ""
echo "Done. Output: $OUT_DIR" echo "Done. Output: $OUT_DIR"
echo "Run on this host: $OUT_DIR/DnsmasqWebUI" echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <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 @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)) @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">
<strong>Recent logs</strong> <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"> <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> <i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button> </button>
@@ -14,17 +24,10 @@
} }
@if (_status.LogsConfigured) @if (_status.LogsConfigured)
{ {
@if (string.IsNullOrWhiteSpace(_status.LogsOutput)) <pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
} }
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center"> <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) @if (_status.LogsPath != null)
{ {
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a> <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.Dnsmasq;
using DnsmasqWebUI.Models.Logs;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Dnsmasq; namespace DnsmasqWebUI.Components.Dnsmasq;
/// <summary> /// <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> /// </summary>
public partial class LogsSection : IDisposable public partial class LogsSection : IAsyncDisposable
{ {
private const string LogsPreId = "dnsmasq-logs-pre";
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private bool _refreshing; private bool _refreshing;
private bool _justUpdated;
private int _intervalSeconds; private int _intervalSeconds;
private HubConnection? _hubConnection;
private IJSObjectReference? _logsJs;
private Timer? _pollTimer;
private Timer? _justUpdatedResetTimer;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15; [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; } [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() private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300); _status = Status;
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return; if (next == _intervalSeconds) return;
_intervalSeconds = next; _intervalSeconds = next;
_timer?.Dispose(); RestartPollTimer();
_timer = new Timer( }
_ => _ = InvokeAsync(OnRefreshTick),
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, null,
TimeSpan.FromSeconds(_intervalSeconds), TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds)); TimeSpan.FromSeconds(_intervalSeconds));
} }
private async Task OnRefreshTick() private async Task PollTick()
{ {
if (_cts.Token.IsCancellationRequested) return; if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
await RefreshAsync(); return;
StateHasChanged(); await RequestRefreshAsync();
} }
private async Task RefreshAsync() private async Task RequestRefreshAsync()
{ {
if (_hubConnection?.State != HubConnectionState.Connected)
return;
_refreshing = true; _refreshing = true;
StateHasChanged();
try try
{ {
var token = _cts.Token; await _hubConnection.InvokeAsync("RequestDnsmasqLogs", _cts.Token);
_status = await StatusClient.GetStatusAsync(token);
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
catch catch (Exception)
{ {
// Don't overwrite on background refresh // Don't overwrite on background refresh
} }
finally finally
{ {
_refreshing = false; _refreshing = false;
StateHasChanged();
} }
} }
public void Dispose() private async void OnDnsmasqLogsUpdate(LogsUpdatePayload payload)
{ {
_timer?.Dispose(); try
_timer = null; {
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.Cancel();
_cts.Dispose(); _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.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -25,7 +26,7 @@ public partial class StatusSection : IDisposable
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300); _intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
await RefreshAsync(); await RefreshAsync();
_timer = new Timer( _timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick), _ => _ = InvokeAsync(OnRefreshTick),
@@ -36,7 +37,7 @@ public partial class StatusSection : IDisposable
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300); var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
if (next == _intervalSeconds) return; if (next == _intervalSeconds) return;
_intervalSeconds = next; _intervalSeconds = next;
_timer?.Dispose(); _timer?.Dispose();
@@ -3,16 +3,18 @@
@inject ISettingsModalService SettingsModalService @inject ISettingsModalService SettingsModalService
<div class="top-row ps-3 navbar navbar-dark"> <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"> <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 class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
<span>@AppTitle</span> <span>@AppTitle</span>
</a> </a>
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings"> <div class="nav-top-controls">
<i class="bi bi-gear-fill" aria-hidden="true"></i> <button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
</button> <i class="bi bi-gear-fill" aria-hidden="true"></i>
</button>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
</div>
</div> </div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
</div> </div>
<div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()"> <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 { .navbar-toggler {
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
flex-shrink: 0;
width: 3.5rem; width: 3.5rem;
height: 2.5rem; height: 2.5rem;
color: white; color: white;
position: absolute;
top: 50%;
right: 1rem;
transform: translateY(-50%);
border: 1px solid rgba(255, 255, 255, 0.1); 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); 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); background-color: rgba(255, 255, 255, 0.5);
} }
.top-row { /* Settings button: same height as toggler on mobile for alignment */
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. */
.nav-settings-btn { .nav-settings-btn {
margin-left: auto;
margin-right: 0.5rem;
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
height: 2.5rem;
box-sizing: border-box;
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem; border-radius: 0.375rem;
@@ -47,25 +62,29 @@
font-size: 1.25rem; 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 { .navbar-brand {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
color: rgba(255, 255, 255, 0.95) !important; color: rgba(255, 255, 255, 0.95) !important;
flex-shrink: 0;
} }
.navbar-brand:hover { .navbar-brand:hover {
color: white !important; 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 */ /* Bootstrap Icons in nav: white, sized to match link text */
.nav-item .nav-icon { .nav-item .nav-icon {
@@ -116,7 +135,8 @@
display: none; 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; display: block;
} }
@@ -2,6 +2,7 @@
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject IClientSettingsService ClientSettingsService @inject IClientSettingsService ClientSettingsService
@inject ILogger<SettingsModal> Logger
@if (IsVisible) @if (IsVisible)
{ {
@@ -12,6 +13,18 @@
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button> <button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <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) @if (SettingsContext == SettingsModalContext.All)
{ {
<div class="mb-3"> <div class="mb-3">
@@ -26,7 +39,7 @@
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">@meta.FieldLabel</label> <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" /> @bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section> </section>
} }
@@ -36,20 +49,62 @@
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">@meta.FieldLabel</label> <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" /> @bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section> </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)) @if (ShouldShowSection(SettingsModalSections.Leases))
{ {
var meta = SettingsModalSections.All[SettingsModalSections.Leases]; var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">@meta.DisplayName</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">@meta.FieldLabel</label> <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" /> @bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section> </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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button> <button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
@@ -16,6 +16,7 @@ public partial class SettingsModal : IAsyncDisposable
private IJSObjectReference? _jsModule; private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef; private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new(); private ClientSettings _editingSettings = new();
private List<string> _validationErrors = new();
private string _searchTerm = string.Empty; private string _searchTerm = string.Empty;
private bool _moduleLoaded; private bool _moduleLoaded;
private bool _dialogInitialized; private bool _dialogInitialized;
@@ -39,12 +40,18 @@ public partial class SettingsModal : IAsyncDisposable
if (justOpened) if (justOpened)
{ {
_searchTerm = string.Empty; _searchTerm = string.Empty;
_validationErrors.Clear();
_editingSettings = await ClientSettingsService.LoadSettingsAsync(); _editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings _editingSettings = new ClientSettings
{ {
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds, ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds, RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
}; };
} }
} }
@@ -53,9 +60,15 @@ public partial class SettingsModal : IAsyncDisposable
{ {
if (firstRender) if (firstRender)
{ {
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js"); try
_dotNetRef = DotNetObjectReference.Create(this); {
_moduleLoaded = true; _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) if (IsVisible && _moduleLoaded && _jsModule != null)
@@ -73,31 +86,48 @@ public partial class SettingsModal : IAsyncDisposable
private bool ShouldShowSection(string key) private bool ShouldShowSection(string key)
{ {
var focusedKey = SettingsModalSections.GetSectionKeyForContext(SettingsContext); var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
if (focusedKey != null) if (keys != null)
return string.Equals(key, focusedKey, StringComparison.OrdinalIgnoreCase); return keys.Any(k => string.Equals(key, k, StringComparison.OrdinalIgnoreCase));
return SettingsModalSections.MatchesSearch(key, _searchTerm); 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() private async Task Save()
{ {
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp( RunValidation();
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300); if (_validationErrors.Count > 0)
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp( {
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300); await InvokeAsync(StateHasChanged);
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp( return;
_editingSettings.LeasesPollingIntervalSeconds, 5, 300); }
await ClientSettingsService.SaveSettingsAsync(_editingSettings); await ClientSettingsService.SaveSettingsAsync(_editingSettings);
if (_jsModule != null) await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync(); await OnClose.InvokeAsync();
} }
private async Task Close() private async Task Close()
{ {
if (_jsModule != null) await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync(); await OnClose.InvokeAsync();
} }
@@ -113,13 +143,12 @@ public partial class SettingsModal : IAsyncDisposable
/// </summary> /// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
try if (_jsModule != null)
{
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; _jsModule = null;
} }
_dotNetRef?.Dispose(); _dotNetRef?.Dispose();
+9 -3
View File
@@ -399,10 +399,16 @@ else
("2592000", "30 days"), ("2592000", "30 days"),
}; };
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
SettingsModalService.SettingsChanged += OnSettingsChanged; SettingsModalService.SettingsChanged += OnSettingsChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await Load(); await Load();
StateHasChanged();
} }
private async Task Load() private async Task Load()
@@ -412,7 +418,7 @@ else
try try
{ {
var clientSettings = await ClientSettingsService.LoadSettingsAsync(); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); _leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
_status = await StatusClient.GetStatusAsync(); _status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync(); var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>(); _entries = entries?.ToList() ?? new List<DhcpHostEntry>();
@@ -433,7 +439,7 @@ else
await InvokeAsync(async () => await InvokeAsync(async () =>
{ {
var clientSettings = await ClientSettingsService.LoadSettingsAsync(); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); _leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
if (_status != null && _status.LeasesPathConfigured) if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer(); StartLeasesRefreshTimer();
StateHasChanged(); StateHasChanged();
@@ -79,7 +79,8 @@ else if (_status != null)
} }
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" /> <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> </section>
} }
@@ -87,6 +88,11 @@ else if (_status != null)
private string AppTitle => AppOptions!.Value!.EffectiveTitle; private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15; private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 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 DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet; private DnsmasqConfigSet? _configSet;
@@ -98,17 +104,27 @@ else if (_status != null)
private bool _reloadSuccess; private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
SettingsModalService.SettingsChanged += OnSettingsChanged; SettingsModalService.SettingsChanged += OnSettingsChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_loading = true; _loading = true;
try try
{ {
await Task.Delay(1, _cts.Token); await Task.Delay(1, _cts.Token);
var token = _cts.Token; var token = _cts.Token;
var clientSettings = await ClientSettingsService.LoadSettingsAsync(); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300); _serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300); _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); _status = await StatusClient.GetStatusAsync(token);
if (_status != null) if (_status != null)
{ {
@@ -133,6 +149,7 @@ else if (_status != null)
finally finally
{ {
_loading = false; _loading = false;
StateHasChanged();
} }
} }
@@ -140,13 +157,20 @@ else if (_status != null)
private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling"); private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
private void OpenAppLogsSettings() => SettingsModalService.Open(SettingsModalContext.AppLogsPolling, "App logs polling");
private async void OnSettingsChanged() private async void OnSettingsChanged()
{ {
await InvokeAsync(async () => await InvokeAsync(async () =>
{ {
var clientSettings = await ClientSettingsService.LoadSettingsAsync(); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300); _serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300); _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(); StateHasChanged();
}); });
} }
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -14,12 +16,14 @@ public class ConfigController : ControllerBase
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService; private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService; 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; _configSetService = configSetService;
_configService = configService; _configService = configService;
_reloadService = reloadService; _reloadService = reloadService;
_logger = logger;
} }
/// <summary>Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI.</summary> /// <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 try
{ {
_logger.LogDebug("Get config set");
var set = await _configSetService.GetConfigSetAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
return Ok(set); return Ok(set);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.ConfigGetSetFailed, ex, "Get config set failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -43,11 +49,13 @@ public class ConfigController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get managed config");
var content = await _configService.ReadManagedConfigAsync(ct); var content = await _configService.ReadManagedConfigAsync(ct);
return Ok(content); return Ok(content);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.ConfigGetManagedFailed, ex, "Get managed config failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -62,10 +70,12 @@ public class ConfigController : ControllerBase
{ {
await _configService.WriteManagedConfigAsync(lines, ct); await _configService.WriteManagedConfigAsync(lines, ct);
var reload = await _reloadService.ReloadAsync(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 } }); return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.ConfigPutManagedFailed, ex, "Put managed config failed");
return StatusCode(500, new { error = ex.Message }); 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.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -11,11 +13,13 @@ public class DhcpController : ControllerBase
{ {
private readonly IDnsmasqConfigService _configService; private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService; 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; _configService = configService;
_reloadService = reloadService; _reloadService = reloadService;
_logger = logger;
} }
[HttpGet("hosts")] [HttpGet("hosts")]
@@ -23,11 +27,13 @@ public class DhcpController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get DHCP hosts");
var entries = await _configService.ReadDhcpHostsAsync(ct); var entries = await _configService.ReadDhcpHostsAsync(ct);
return Ok(entries); return Ok(entries);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.DhcpGetHostsFailed, ex, "Get DHCP hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -41,14 +47,17 @@ public class DhcpController : ControllerBase
{ {
await _configService.WriteDhcpHostsAsync(entries, ct); await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(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)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {
_logger.LogWarning(LogEvents.DhcpPutHostsValidationFailed, ex, "DHCP hosts put validation failed");
return BadRequest(new { error = ex.Message }); return BadRequest(new { error = ex.Message });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.DhcpPutHostsFailed, ex, "Put DHCP hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -12,12 +14,14 @@ public class HostsController : ControllerBase
private readonly IHostsFileService _hostsService; private readonly IHostsFileService _hostsService;
private readonly IReloadService _reloadService; private readonly IReloadService _reloadService;
private readonly IHostsCache _hostsCache; 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; _hostsService = hostsService;
_reloadService = reloadService; _reloadService = reloadService;
_hostsCache = hostsCache; _hostsCache = hostsCache;
_logger = logger;
} }
[HttpGet] [HttpGet]
@@ -25,11 +29,13 @@ public class HostsController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get hosts");
var entries = await _hostsService.ReadAsync(ct); var entries = await _hostsService.ReadAsync(ct);
return Ok(entries); return Ok(entries);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -40,11 +46,13 @@ public class HostsController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get readonly hosts");
var snapshot = await _hostsCache.GetSnapshotAsync(ct); var snapshot = await _hostsCache.GetSnapshotAsync(ct);
return Ok(snapshot.ReadOnlyFiles); return Ok(snapshot.ReadOnlyFiles);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.HostsGetReadOnlyFailed, ex, "Get readonly hosts failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -58,14 +66,17 @@ public class HostsController : ControllerBase
{ {
await _hostsService.WriteAsync(entries, ct); await _hostsService.WriteAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(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)); return Ok(new SaveWithReloadResult(true, reload));
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
_logger.LogWarning(LogEvents.HostsPutValidationFailed, ex, "Hosts put validation failed");
return BadRequest(new { error = ex.Message }); return BadRequest(new { error = ex.Message });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed");
return StatusCode(500, new { error = ex.Message }); 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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -10,11 +12,13 @@ public class LeasesController : ControllerBase
{ {
private readonly ILeasesFileService _leasesService; private readonly ILeasesFileService _leasesService;
private readonly ILeasesCache _cache; 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; _leasesService = leasesService;
_cache = cache; _cache = cache;
_logger = logger;
} }
[HttpGet] [HttpGet]
@@ -22,6 +26,7 @@ public class LeasesController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get leases, refresh={Refresh}", refresh);
if (refresh) if (refresh)
_cache.Invalidate(); _cache.Invalidate();
var (available, entries) = await _leasesService.TryReadAsync(ct); var (available, entries) = await _leasesService.TryReadAsync(ct);
@@ -33,6 +38,7 @@ public class LeasesController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed");
return StatusCode(500, new { error = ex.Message }); 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 DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -8,10 +10,12 @@ namespace DnsmasqWebUI.Controllers;
public class ReloadController : ControllerBase public class ReloadController : ControllerBase
{ {
private readonly IReloadService _reloadService; private readonly IReloadService _reloadService;
private readonly ILogger<ReloadController> _logger;
public ReloadController(IReloadService reloadService) public ReloadController(IReloadService reloadService, ILogger<ReloadController> logger)
{ {
_reloadService = reloadService; _reloadService = reloadService;
_logger = logger;
} }
[HttpPost] [HttpPost]
@@ -20,10 +24,12 @@ public class ReloadController : ControllerBase
try try
{ {
var result = await _reloadService.ReloadAsync(ct); var result = await _reloadService.ReloadAsync(ct);
_logger.LogInformation(LogEvents.ReloadRequestSuccess, "Reload requested, success={Success}", result.Success);
return Ok(result); return Ok(result);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Controllers; namespace DnsmasqWebUI.Controllers;
@@ -15,15 +17,18 @@ public class StatusController : ControllerBase
private readonly DnsmasqOptions _options; private readonly DnsmasqOptions _options;
private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigSetService _configSetService;
private readonly IProcessRunner _processRunner; private readonly IProcessRunner _processRunner;
private readonly ILogger<StatusController> _logger;
public StatusController( public StatusController(
IOptions<DnsmasqOptions> options, IOptions<DnsmasqOptions> options,
IDnsmasqConfigSetService configSetService, IDnsmasqConfigSetService configSetService,
IProcessRunner processRunner) IProcessRunner processRunner,
ILogger<StatusController> logger)
{ {
_options = options.Value; _options = options.Value;
_configSetService = configSetService; _configSetService = configSetService;
_processRunner = processRunner; _processRunner = processRunner;
_logger = logger;
} }
[HttpGet] [HttpGet]
@@ -31,6 +36,7 @@ public class StatusController : ControllerBase
{ {
try try
{ {
_logger.LogDebug("Get status");
var set = await _configSetService.GetConfigSetAsync(ct); var set = await _configSetService.GetConfigSetAsync(ct);
var effectiveLeasesPath = _configSetService.GetLeasesPath(); var effectiveLeasesPath = _configSetService.GetLeasesPath();
var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources(); var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources();
@@ -98,6 +104,7 @@ public class StatusController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
@@ -140,6 +147,7 @@ public class StatusController : ControllerBase
try try
{ {
_logger.LogDebug("Get status logs download");
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath)); var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
if (!System.IO.File.Exists(fullPath)) if (!System.IO.File.Exists(fullPath))
return NotFound(); return NotFound();
@@ -154,6 +162,7 @@ public class StatusController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(LogEvents.StatusLogsDownloadFailed, ex, "Status logs download failed");
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
+2 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). --> <!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
@@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
<PackageReference Include="Superpower" Version="3.1.0" /> <PackageReference Include="Superpower" Version="3.1.0" />
</ItemGroup> </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<IHostsClient, HostsClient>(DnsmasqApiClientName);
services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName); services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName);
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName); services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
services.AddHttpClient<ILoggingClient, LoggingClient>(DnsmasqApiClientName);
return services; return services;
} }
@@ -20,7 +20,7 @@ public static class WebApplicationExtensions
app.UseForwardedHeaders(new ForwardedHeadersOptions app.UseForwardedHeaders(new ForwardedHeadersOptions
{ {
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) }, KnownIPNetworks = { new System.Net.IPNetwork(IPAddress.Loopback, 8) },
}); });
return app; 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> /// <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); 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 public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
{ {
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
private readonly ILogger<ClientSettingsService> _logger;
private IJSObjectReference? _module; private IJSObjectReference? _module;
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -20,13 +21,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
WriteIndented = false 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; if (_module != null) return _module;
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>( try
"import", "./js/client-settings-storage.js"); {
_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; return _module;
} }
@@ -34,6 +45,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
public async Task<ClientSettings> LoadSettingsAsync() public async Task<ClientSettings> LoadSettingsAsync()
{ {
var module = await GetModuleAsync(); var module = await GetModuleAsync();
if (module == null) return new ClientSettings();
var json = await module.InvokeAsync<string?>("getItem"); var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings(); if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
try try
@@ -49,8 +61,9 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
/// <inheritdoc /> /// <inheritdoc />
public async Task SaveSettingsAsync(ClientSettings settings) public async Task SaveSettingsAsync(ClientSettings settings)
{ {
var json = JsonSerializer.Serialize(settings, JsonOptions);
var module = await GetModuleAsync(); var module = await GetModuleAsync();
if (module == null) return;
var json = JsonSerializer.Serialize(settings, JsonOptions);
await module.InvokeVoidAsync("setItem", json); await module.InvokeVoidAsync("setItem", json);
} }
@@ -60,13 +73,10 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_module == null) return; if (_module == null) return;
try try { await _module.DisposeAsync(); }
{ catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped (prerender)"); }
await _module.DisposeAsync(); catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped: circuit disconnected"); }
} catch (JSException ex) { _logger.LogDebug(ex, "JS module DisposeAsync failed"); }
finally finally { _module = null; }
{
_module = null;
}
} }
} }
@@ -1,12 +1,14 @@
using System.Text; using System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config; 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.Config;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers; using Microsoft.Extensions.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services; namespace DnsmasqWebUI.Infrastructure.Services;
@@ -34,7 +36,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var snapshot = await _configSetCache.GetSnapshotAsync(ct); var snapshot = await _configSetCache.GetSnapshotAsync(ct);
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath)) 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>(); return Array.Empty<DhcpHostEntry>();
} }
var allEntries = snapshot.DhcpHostEntries.ToList(); var allEntries = snapshot.DhcpHostEntries.ToList();
@@ -149,7 +151,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
EnsureManagedHostsFileExists(set.ManagedHostsFilePath); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp)); _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> /// <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); EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? ""; var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath)); _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 System.Text;
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers; using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services; namespace DnsmasqWebUI.Infrastructure.Services;
@@ -79,7 +81,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
lines.Add(""); lines.Add("");
lines.Add(confFileLine); lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken); 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 else
{ {
@@ -91,7 +93,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
if (File.Exists(set.ManagedFilePath)) if (File.Exists(set.ManagedFilePath))
return; 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); await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
} }
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config; using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Parsers; using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services; namespace DnsmasqWebUI.Infrastructure.Services;
@@ -39,6 +41,6 @@ public class HostsFileService : IHostsFileService
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct); await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
File.Move(tmpPath, path, overwrite: true); File.Move(tmpPath, path, overwrite: true);
_hostsCache.NotifyWeWroteManagedHosts(entries); _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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Dhcp;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services; namespace DnsmasqWebUI.Infrastructure.Services;
public class LeasesFileService : ILeasesFileService public class LeasesFileService : ILeasesFileService
{ {
private readonly ILeasesCache _cache; private readonly ILeasesCache _cache;
private readonly ILogger<LeasesFileService> _logger;
public LeasesFileService(ILeasesCache cache) public LeasesFileService(ILeasesCache cache, ILogger<LeasesFileService> logger)
{ {
_cache = cache; _cache = cache;
_logger = logger;
} }
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default) 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) 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.Diagnostics;
using System.Text; using System.Text;
using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Infrastructure.Logging;
using DnsmasqWebUI.Infrastructure.Services.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Contracts;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services; 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> /// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
public sealed class ProcessRunner : IProcessRunner public sealed class ProcessRunner : IProcessRunner
{ {
private const int MaxCommandPrefixLength = 80;
private readonly ILogger<ProcessRunner> _logger; private readonly ILogger<ProcessRunner> _logger;
public ProcessRunner(ILogger<ProcessRunner> logger) => _logger = 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)) if (string.IsNullOrWhiteSpace(command))
return new ProcessRunResult(null, "", "", false); 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 stdout = new StringBuilder();
var stderr = new StringBuilder(); var stderr = new StringBuilder();
using var process = new Process 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); }; var truncMsg = "\n\n(output truncated)\n";
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; 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 try
{ {
@@ -50,7 +83,7 @@ public sealed class ProcessRunner : IProcessRunner
} }
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) 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 */ } try { process.Kill(); } catch { /* best effort */ }
var err = stderr.ToString(); var err = stderr.ToString();
if (!string.IsNullOrEmpty(err)) err += "\n"; if (!string.IsNullOrEmpty(err)) err += "\n";
@@ -58,15 +91,17 @@ public sealed class ProcessRunner : IProcessRunner
return new ProcessRunResult(null, stdout.ToString(), err, true); 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( return new ProcessRunResult(
process.HasExited ? process.ExitCode : -1, exitCode,
stdout.ToString(), stdout.ToString(),
stderr.ToString(), stderr.ToString(),
false); false);
} }
catch (Exception ex) 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); 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.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Config;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services; namespace DnsmasqWebUI.Infrastructure.Services;
@@ -22,13 +24,13 @@ public class ReloadService : IReloadService
{ {
if (string.IsNullOrWhiteSpace(_options.ReloadCommand)) 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"); return new ReloadResult(true, 0, null, "Reload command not configured");
} }
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct)) 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."); 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; stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
if (result.ExitCode != 0 && result.ExitCode.HasValue) 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) else if (result.ExitCode == 0)
_logger.LogInformation("Reload command succeeded"); _logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded");
return new ReloadResult( return new ReloadResult(
result.ExitCode == 0, result.ExitCode == 0,
@@ -54,7 +56,7 @@ public class ReloadService : IReloadService
} }
catch (Exception ex) 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); return new ReloadResult(false, -1, null, ex.Message);
} }
finally finally
@@ -8,9 +8,24 @@ public class ClientSettings
/// <summary>Polling interval for the service status block (seconds).</summary> /// <summary>Polling interval for the service status block (seconds).</summary>
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15; 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; 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> /// <summary>Polling interval for the DHCP leases list (seconds).</summary>
public int LeasesPollingIntervalSeconds { get; set; } = 5; 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> /// <summary>Show only service status polling interval.</summary>
ServicePolling, ServicePolling,
/// <summary>Show only recent logs polling interval.</summary> /// <summary>Show only recent dnsmasq logs polling interval.</summary>
LogsPolling, LogsPolling,
/// <summary>Show only app logs polling interval.</summary>
AppLogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary> /// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling LeasesPolling
} }
@@ -25,7 +28,10 @@ public static class SettingsModalSections
{ {
public const string ServiceStatus = "ServiceStatus"; public const string ServiceStatus = "ServiceStatus";
public const string Logs = "Logs"; public const string Logs = "Logs";
public const string AppLogs = "AppLogs";
public const string Leases = "Leases"; 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> /// <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); 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) 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"), [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"), [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> /// <summary>Maps a focused context (single-section view) to its section keys. Returns null for All (search mode).</summary>
public static string? GetSectionKeyForContext(SettingsModalContext context) => context switch public static string[]? GetSectionKeysForContext(SettingsModalContext context) => context switch
{ {
SettingsModalContext.ServicePolling => ServiceStatus, SettingsModalContext.ServicePolling => [ServiceStatus],
SettingsModalContext.LogsPolling => Logs, SettingsModalContext.LogsPolling => [Logs, RecentLogsDisplay],
SettingsModalContext.LeasesPolling => Leases, SettingsModalContext.AppLogsPolling => [AppLogs, AppLogsDisplay],
SettingsModalContext.LeasesPolling => [Leases],
_ => null _ => 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) public static bool MatchesSearch(string key, string searchTerm)
{ {
if (string.IsNullOrWhiteSpace(searchTerm)) return true; 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.Components;
using DnsmasqWebUI.Hubs;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Helpers.Http; using DnsmasqWebUI.Infrastructure.Helpers.Http;
@@ -12,9 +13,19 @@ var builder = isDevelopment
? WebApplication.CreateBuilder(args) ? WebApplication.CreateBuilder(args)
: WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), Args = 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.) ---- // ---- Application options (title, etc.) ----
builder.Services.AddOptions<ApplicationOptions>() builder.Services.AddOptions<ApplicationOptions>()
.Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName)); .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) ---- // ---- Dnsmasq options (required paths validated at startup) ----
builder.Services.AddOptions<DnsmasqOptions>() builder.Services.AddOptions<DnsmasqOptions>()
@@ -30,9 +41,11 @@ builder.Services.AddDnsmasqApiHttpClients();
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions)); .AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions));
builder.Services.AddSignalR();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Logging.AddAppLogs(builder.Configuration);
builder.Services.AddCorsFromConfiguration(builder.Configuration); builder.Services.AddCorsFromConfiguration(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
@@ -59,6 +72,7 @@ app.UseAntiforgery();
// ---- Endpoints ---- // ---- Endpoints ----
app.MapControllers(); app.MapControllers();
app.MapHub<LogsHub>("/hubs/logs");
// UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly. // UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly.
app.UseStaticFiles(); app.UseStaticFiles();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
+15 -2
View File
@@ -5,9 +5,22 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "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": "*", "AllowedHosts": "*",
"ForwardedHeaders": { "ForwardedHeaders": {
"Enabled": false "Enabled": false
@@ -33,4 +46,4 @@
"StatusShowCommand": "systemctl status dnsmasq --no-pager", "StatusShowCommand": "systemctl status dnsmasq --no-pager",
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager" "LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"
} }
} }
+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;
}