using DnsmasqWebUI.Extensions.Interop; using DnsmasqWebUI.Infrastructure.Client.Abstractions; using DnsmasqWebUI.Models.Logs; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.JSInterop; namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs; /// /// 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 bool _logsContentReceived; private bool _refreshing; 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() { if (RefreshIntervalSeconds == _intervalSeconds) return; _intervalSeconds = RefreshIntervalSeconds; RestartPollTimer(); } protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; _intervalSeconds = RefreshIntervalSeconds; var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString(); _hubConnection = new HubConnectionBuilder() .WithUrl(hubUri) .WithAutomaticReconnect() .Build(); _hubConnection.On("AppLogsUpdate", OnAppLogsUpdate); _logsJs = await JSRuntime.InvokeAsyncSafe("import", default, "./js/logs.js"); if (_logsJs == null) { Logger.LogDebug("AppLogsSection: logs JS module unavailable (prerender, disconnect, or import failure)"); 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 RequestRefreshAsync() { if (_hubConnection?.State != HubConnectionState.Connected) return; _refreshing = true; StateHasChanged(); try { await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token); } catch (OperationCanceledException) { } catch (Exception) { /* ignore */ } finally { _refreshing = false; StateHasChanged(); } } 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; if (payload.Mode == "replace") await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions); else await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions); _logsContentReceived = true; SetJustUpdated(); StateHasChanged(); }); } catch (ObjectDisposedException) { /* Component disposed; ignore */ } catch (InvalidOperationException) { /* Circuit disconnected; ignore */ } catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ } } 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; } await _logsJs.DisposeAsyncSafe(); _logsJs = null; } }