From 1bdd176c18956e86f87a8aef265d78c4c4b56163 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 10 Feb 2026 08:44:30 +1000 Subject: [PATCH] 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 --- .github/workflows/release.yml | 6 +- .gitignore | 3 +- Dockerfile | 4 +- README.md | 2 +- docker-compose.test.yml | 2 + scripts/install.sh | 4 +- scripts/prepare-test-mount.sh | 31 ++- scripts/publish-self-contained.sh | 4 +- .../DnsmasqWebUI.Tests.csproj | 2 +- .../Dnsmasq/AppLogsFiltersModal.razor | 46 ++++ .../Dnsmasq/AppLogsFiltersModal.razor.cs | 154 ++++++++++++ .../Dnsmasq/AppLogsFiltersModal.razor.css | 47 ++++ .../Components/Dnsmasq/AppLogsSection.razor | 33 +++ .../Dnsmasq/AppLogsSection.razor.cs | 195 ++++++++++++++++ .../Dnsmasq/AppLogsSection.razor.css | 13 ++ .../Components/Dnsmasq/LogsSection.razor | 27 ++- .../Components/Dnsmasq/LogsSection.razor.cs | 160 ++++++++++--- .../Components/Dnsmasq/LogsSection.razor.css | 13 ++ .../Components/Dnsmasq/StatusSection.razor.cs | 5 +- .../Components/Layout/NavMenu.razor | 12 +- .../Components/Layout/NavMenu.razor.css | 64 +++-- .../Components/Modals/SettingsModal.razor | 61 ++++- .../Components/Modals/SettingsModal.razor.cs | 75 ++++-- src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 12 +- .../Components/Pages/Dnsmasq.razor | 36 ++- .../Controllers/ConfigController.cs | 16 +- .../Controllers/DhcpController.cs | 13 +- .../Controllers/HostsController.cs | 17 +- .../Controllers/LeasesController.cs | 10 +- .../Controllers/LoggingController.cs | 221 ++++++++++++++++++ .../Controllers/ReloadController.cs | 8 +- .../Controllers/StatusController.cs | 17 +- src/DnsmasqWebUI/DnsmasqWebUI.csproj | 3 +- .../Extensions/LoggingBuilderExtensions.cs | 38 +++ .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Extensions/WebApplicationExtensions.cs | 2 +- src/DnsmasqWebUI/Hubs/LogsHub.cs | 42 ++++ .../Client/Abstractions/ILoggingClient.cs | 11 + .../Infrastructure/Client/LoggingClient.cs | 51 ++++ .../Logging/AppLogsConfigHelper.cs | 30 +++ .../Logging/AppLogsLoggerProvider.cs | 89 +++++++ .../Infrastructure/Logging/LogEvents.cs | 70 ++++++ .../Services/Abstractions/IAppLogsBuffer.cs | 16 ++ .../Services/Abstractions/ILogsService.cs | 24 ++ .../Services/Abstractions/IProcessRunner.cs | 3 + .../Infrastructure/Services/AppLogsBuffer.cs | 43 ++++ .../AppLogsPushTriggeredHostedService.cs | 65 ++++++ ...pplicationLifecycleLoggingHostedService.cs | 29 +++ .../Services/ClientSettingsService.cs | 36 +-- .../Services/DnsmasqConfigService.cs | 12 +- .../EnsureManagedConfigHostedService.cs | 10 +- .../Services/HostsFileService.cs | 6 +- .../Services/LeasesFileService.cs | 15 +- .../Services/LogsFileWatcherHostedService.cs | 141 +++++++++++ .../Infrastructure/Services/LogsService.cs | 202 ++++++++++++++++ .../Infrastructure/Services/ProcessRunner.cs | 49 +++- .../Infrastructure/Services/ReloadService.cs | 14 +- .../Models/Client/ClientSettings.cs | 17 +- .../Models/Client/ClientSettingsFields.cs | 23 ++ .../Models/Client/SettingsModalSections.cs | 31 ++- .../Models/Config/AppLogsOptions.cs | 27 +++ .../Models/Config/RuntimeOverridesOptions.cs | 19 ++ .../Models/Logs/LogsUpdatePayload.cs | 13 ++ src/DnsmasqWebUI/Program.cs | 14 ++ src/DnsmasqWebUI/appsettings.json | 17 +- src/DnsmasqWebUI/wwwroot/js/logs.js | 37 +++ 66 files changed, 2312 insertions(+), 201 deletions(-) create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.css create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.css create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.css create mode 100644 src/DnsmasqWebUI/Controllers/LoggingController.cs create mode 100644 src/DnsmasqWebUI/Extensions/LoggingBuilderExtensions.cs create mode 100644 src/DnsmasqWebUI/Hubs/LogsHub.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Client/Abstractions/ILoggingClient.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Client/LoggingClient.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Logging/AppLogsConfigHelper.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Logging/AppLogsLoggerProvider.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Logging/LogEvents.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Abstractions/IAppLogsBuffer.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Abstractions/ILogsService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/AppLogsBuffer.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/AppLogsPushTriggeredHostedService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/ApplicationLifecycleLoggingHostedService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/LogsFileWatcherHostedService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/LogsService.cs create mode 100644 src/DnsmasqWebUI/Models/Client/ClientSettingsFields.cs create mode 100644 src/DnsmasqWebUI/Models/Config/AppLogsOptions.cs create mode 100644 src/DnsmasqWebUI/Models/Config/RuntimeOverridesOptions.cs create mode 100644 src/DnsmasqWebUI/Models/Logs/LogsUpdatePayload.cs create mode 100644 src/DnsmasqWebUI/wwwroot/js/logs.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bd21a4..b2aea3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,14 @@ jobs: run: | docker run --rm \ -v "${{ github.workspace }}:/src" -w /src \ - mcr.microsoft.com/dotnet/sdk:9.0 \ + mcr.microsoft.com/dotnet/sdk:10.0 \ ./scripts/publish-self-contained.sh ${{ matrix.rid }} - name: Setup .NET if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Publish for ${{ matrix.rid }} (musl on host) if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64' @@ -44,7 +44,7 @@ jobs: - name: Zip publish output run: | - PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish" + PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish" (cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .) - name: Upload artifact diff --git a/.gitignore b/.gitignore index 484e56c..913a254 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ testdata-mount/ .DS_Store Thumbs.db -# User-specific +# User-specific / runtime appsettings.*.local.json +appsettings.Overrides.json *.local diff --git a/Dockerfile b/Dockerfile index 96e41e9..c74c4d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,12 @@ # For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh) # or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope. -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"] RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj" diff --git a/README.md b/README.md index d0a46c6..518dbff 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ sudo ./scripts/install.sh --uninstall --purge --system ./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64 ``` -**Output:** `src/DnsmasqWebUI/bin/Release/net9.0//publish/` +**Output:** `src/DnsmasqWebUI/bin/Release/net10.0//publish/` --- diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 79db599..b96a220 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -26,6 +26,8 @@ services: # Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf) Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh" Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'" + # Persist runtime log level across container restarts + RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json" volumes: - ${TESTDATA_MOUNT:-./testdata-mount}:/data networks: diff --git a/scripts/install.sh b/scripts/install.sh index 8c2355d..ef33bc1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -346,7 +346,7 @@ do_install_from_source() { exit 1 fi if ! command -v dotnet >/dev/null 2>&1; then - echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2 + echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2 exit 1 fi @@ -371,7 +371,7 @@ do_install_from_source() { echo "Error: Could not determine RID from build output." >&2 exit 1 fi - publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish" + publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish" if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then echo "Error: Build output not found at $publish_dir" >&2 exit 1 diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index ac59025..e20f915 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -3,7 +3,7 @@ # (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml. # # What this script does: -# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data, +# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data, # then docker compose up -d [--build] [--force-recreate]. # --stop: docker compose down (stop and remove containers/networks). # --tidy: docker compose down, then clear the mount directory for a clean next run. @@ -18,7 +18,7 @@ MOUNT_DIR="" PREPARE_ONLY=false BUILD=false RECREATE=false -NO_CLEAR=false +CLEAR=false STOP=false TIDY=false @@ -26,10 +26,10 @@ usage() { echo "Usage: $0 [OPTIONS] [--]" echo "" echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness" - echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)." + echo "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)." echo "" echo "Steps (when starting):" - echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount." + echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount." echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean." echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]." echo "" @@ -39,9 +39,8 @@ usage() { echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount." echo "" echo "Mount behaviour:" - echo " (default) Clear mount dir completely, then sync. Use for a clean run." - echo " --no-clear Do not clear mount dir; only sync over existing contents." - echo " Use to preserve leases or debug files between runs." + echo " (default) Preserve mount dir; sync source over existing contents." + echo " --clear Clear mount dir completely before sync. Use for a clean run." echo "" echo "Compose behaviour:" echo " --prepare-only Only prepare the mount; do not run docker compose." @@ -62,19 +61,19 @@ usage() { echo "" echo "Examples:" echo " $0" - echo " Full run: clear mount, sync testdata, start containers (no rebuild)." + echo " Sync testdata to mount (preserving existing), start containers (no rebuild)." echo "" echo " $0 --build" - echo " Clear and sync, then rebuild images and start containers." + echo " Sync, rebuild images, and start containers." echo "" echo " $0 --recreate" - echo " Clear and sync, then up -d --force-recreate (fresh containers)." + echo " Sync, then up -d --force-recreate (fresh containers)." echo "" - echo " $0 --no-clear" - echo " Preserve mount contents, sync over it, start (no rebuild)." + echo " $0 --clear" + echo " Clear mount, sync testdata, start (clean run, no rebuild)." echo "" echo " $0 --prepare-only" - echo " Only clear and sync testdata -> testdata-mount; no containers." + echo " Only sync testdata -> testdata-mount; no containers." echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]" echo "" echo " $0 --source myfixtures --mount mymount --prepare-only" @@ -106,8 +105,8 @@ while [ $# -gt 0 ]; do MOUNT_DIR="$1" shift ;; - --no-clear) - NO_CLEAR=true + --clear) + CLEAR=true shift ;; --prepare-only) @@ -189,7 +188,7 @@ docker compose -f "$COMPOSE_FILE" down mkdir -p "$MOUNT_DIR" -if [ "$NO_CLEAR" = false ]; then +if [ "$CLEAR" = true ]; then echo "Clearing mount directory: $MOUNT_DIR" find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true fi diff --git a/scripts/publish-self-contained.sh b/scripts/publish-self-contained.sh index 3e8eed6..c382aca 100755 --- a/scripts/publish-self-contained.sh +++ b/scripts/publish-self-contained.sh @@ -85,7 +85,7 @@ while [ $# -gt 0 ]; do echo " ubuntu.24.04-x64 ubuntu.24.04-arm64" echo " ubuntu.22.04-x64 ubuntu.22.04-arm64" echo "" - echo "Output: src/DnsmasqWebUI/bin/Release/net9.0//publish/" + echo "Output: src/DnsmasqWebUI/bin/Release/net10.0//publish/" echo "" echo "Examples:" echo " $0 # Publish for current machine (auto-detect RID)" @@ -163,7 +163,7 @@ else --self-contained true fi -OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish" +OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish" echo "" echo "Done. Output: $OUT_DIR" echo "Run on this host: $OUT_DIR/DnsmasqWebUI" diff --git a/src/DnsmasqWebUI.Tests/DnsmasqWebUI.Tests.csproj b/src/DnsmasqWebUI.Tests/DnsmasqWebUI.Tests.csproj index c2c1495..8c6ad0d 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqWebUI.Tests.csproj +++ b/src/DnsmasqWebUI.Tests/DnsmasqWebUI.Tests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor new file mode 100644 index 0000000..eaeed2e --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor @@ -0,0 +1,46 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq +@inject ILoggingClient LoggingClient +@inject IJSRuntime JSRuntime +@implements IAsyncDisposable + +@if (IsVisible) +{ + + + +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs new file mode 100644 index 0000000..226eb44 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs @@ -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? _dotNetRef; + private List _prefixes = []; + private List _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("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 */ } + } + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.css new file mode 100644 index 0000000..af5fca2 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.css @@ -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; +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor new file mode 100644 index 0000000..0b82861 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor @@ -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 Logger +@implements IAsyncDisposable + +

+ + App logs + + + + + + + +

+ +
@_initialPlaceholder
diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs new file mode 100644 index 0000000..48909b0 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs @@ -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; + +/// +/// 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). +/// +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("AppLogsUpdate", OnAppLogsUpdate); + + try + { + _logsJs = await JSRuntime.InvokeAsync("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; + } + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.css new file mode 100644 index 0000000..40cb4af --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.css @@ -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; +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor index ebc085d..546edad 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor @@ -1,9 +1,19 @@ @namespace DnsmasqWebUI.Components.Dnsmasq -@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@ +@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@ +@using DnsmasqWebUI.Models.Logs +@using Microsoft.AspNetCore.SignalR.Client +@inject NavigationManager Navigation +@inject IJSRuntime JSRuntime +@inject ILogger Logger +@implements IAsyncDisposable + @if (_status != null && (_status.LogsConfigured || _status.LogsPath != null)) { -

- Recent logs +

+ + Recent logs + + @@ -14,17 +24,10 @@ } @if (_status.LogsConfigured) { - @if (string.IsNullOrWhiteSpace(_status.LogsOutput)) - { -

No output.

- } - else - { -
@_status.LogsOutput
- } +
@_initialPlaceholder
}
- + @if (_status.LogsPath != null) { Download logs diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs index 93f7e11..b543494 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs @@ -1,81 +1,181 @@ -using DnsmasqWebUI.Infrastructure.Client.Abstractions; +using DnsmasqWebUI.Models.Client; using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Logs; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.JSInterop; namespace DnsmasqWebUI.Components.Dnsmasq; /// -/// 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. /// -public partial class LogsSection : IDisposable +public partial class LogsSection : IAsyncDisposable { + private const string LogsPreId = "dnsmasq-logs-pre"; + private DnsmasqServiceStatus? _status; private bool _refreshing; + private bool _justUpdated; private int _intervalSeconds; + private HubConnection? _hubConnection; + private IJSObjectReference? _logsJs; + private Timer? _pollTimer; + private Timer? _justUpdatedResetTimer; private readonly CancellationTokenSource _cts = new(); - private Timer? _timer; + + [Parameter] public DnsmasqServiceStatus? Status { get; set; } [Parameter] public int RefreshIntervalSeconds { get; set; } = 15; + [Parameter] public int LogsMaxLines { get; set; } = 500; + + [Parameter] public bool LogsAutoScroll { get; set; } = true; + [Parameter] public EventCallback OnOpenSettings { get; set; } - [Inject] private IStatusClient StatusClient { get; set; } = null!; + private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll }; - protected override async Task OnInitializedAsync() - { - _intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300); - await RefreshAsync(); - _timer = new Timer( - _ => _ = InvokeAsync(OnRefreshTick), - null, - TimeSpan.FromSeconds(_intervalSeconds), - TimeSpan.FromSeconds(_intervalSeconds)); - } + private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…"; protected override void OnParametersSet() { - var next = Math.Clamp(RefreshIntervalSeconds, 5, 300); + _status = Status; + var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds); if (next == _intervalSeconds) return; _intervalSeconds = next; - _timer?.Dispose(); - _timer = new Timer( - _ => _ = InvokeAsync(OnRefreshTick), + RestartPollTimer(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + _intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds); + _status = Status; + + var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString(); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUri) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.On("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate); + + try + { + _logsJs = await JSRuntime.InvokeAsync("import", "./js/logs.js"); + } + catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; } + catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; } + catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; } + + await _hubConnection.StartAsync(_cts.Token); + RestartPollTimer(); + + // Initial request + await RequestRefreshAsync(); + } + + private void RestartPollTimer() + { + _pollTimer?.Dispose(); + _pollTimer = new Timer( + _ => _ = InvokeAsync(PollTick), null, TimeSpan.FromSeconds(_intervalSeconds), TimeSpan.FromSeconds(_intervalSeconds)); } - private async Task OnRefreshTick() + private async Task PollTick() { - if (_cts.Token.IsCancellationRequested) return; - await RefreshAsync(); - StateHasChanged(); + if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected) + return; + await RequestRefreshAsync(); } - private async Task RefreshAsync() + private async Task RequestRefreshAsync() { + if (_hubConnection?.State != HubConnectionState.Connected) + return; _refreshing = true; + StateHasChanged(); try { - var token = _cts.Token; - _status = await StatusClient.GetStatusAsync(token); + await _hubConnection.InvokeAsync("RequestDnsmasqLogs", _cts.Token); } catch (OperationCanceledException) { } - catch + catch (Exception) { // Don't overwrite on background refresh } finally { _refreshing = false; + StateHasChanged(); } } - public void Dispose() + private async void OnDnsmasqLogsUpdate(LogsUpdatePayload payload) { - _timer?.Dispose(); - _timer = null; + try + { + await InvokeAsync(async () => + { + if (_logsJs == null) return; + try + { + if (payload.Mode == "replace") + await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions); + else + await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions); + SetJustUpdated(); + StateHasChanged(); + } + catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ } + catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ } + catch (JSException) { /* JS error; ignore */ } + }); + } + catch (ObjectDisposedException) { /* Component disposed; ignore */ } + catch (InvalidOperationException) { /* Circuit disconnected; ignore */ } + } + + private void SetJustUpdated() + { + _justUpdated = true; + _justUpdatedResetTimer?.Dispose(); + _justUpdatedResetTimer = new Timer(_ => + { + _justUpdatedResetTimer?.Dispose(); + _justUpdatedResetTimer = null; + _ = InvokeAsync(() => + { + _justUpdated = false; + StateHasChanged(); + }); + }, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan); + } + + public async ValueTask DisposeAsync() + { + _pollTimer?.Dispose(); + _justUpdatedResetTimer?.Dispose(); _cts.Cancel(); _cts.Dispose(); + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + _hubConnection = null; + } + if (_logsJs != null) + { + try { await _logsJs.DisposeAsync(); } + catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); } + catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); } + catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); } + _logsJs = null; + } } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.css new file mode 100644 index 0000000..4e580c1 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.css @@ -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; +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor.cs index 7aabff4..ab9332c 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor.cs @@ -1,4 +1,5 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions; +using DnsmasqWebUI.Models.Client; using DnsmasqWebUI.Models.Dnsmasq; using Microsoft.AspNetCore.Components; @@ -25,7 +26,7 @@ public partial class StatusSection : IDisposable protected override async Task OnInitializedAsync() { - _intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300); + _intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds); await RefreshAsync(); _timer = new Timer( _ => _ = InvokeAsync(OnRefreshTick), @@ -36,7 +37,7 @@ public partial class StatusSection : IDisposable protected override void OnParametersSet() { - var next = Math.Clamp(RefreshIntervalSeconds, 5, 300); + var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds); if (next == _intervalSeconds) return; _intervalSeconds = next; _timer?.Dispose(); diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index c92c6c3..56249ff 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -3,16 +3,18 @@ @inject ISettingsModalService SettingsModalService