diff --git a/Manager.App/Components/App.razor b/Manager.App/Components/App.razor index 6dfe981..68d8c96 100644 --- a/Manager.App/Components/App.razor +++ b/Manager.App/Components/App.razor @@ -7,6 +7,7 @@ + @@ -17,6 +18,8 @@ + + \ No newline at end of file diff --git a/Manager.App/Components/Application/System/EventConsole.razor b/Manager.App/Components/Application/System/EventConsole.razor new file mode 100644 index 0000000..473c31d --- /dev/null +++ b/Manager.App/Components/Application/System/EventConsole.razor @@ -0,0 +1,21 @@ +@inject IJSRuntime JsRuntime +@implements IDisposable + + + + + Live service events + @($"{_serviceEvents.Count} events") + + Auto-scroll + +
+ +
+ @TimeZoneInfo.ConvertTime(serviceEvent.DateUtc, _timeZone)  + @serviceEvent.Severity [@serviceEvent.Source]  + @serviceEvent.Message +
+
+
+
\ No newline at end of file diff --git a/Manager.App/Components/Application/System/EventConsole.razor.cs b/Manager.App/Components/Application/System/EventConsole.razor.cs new file mode 100644 index 0000000..ca4787c --- /dev/null +++ b/Manager.App/Components/Application/System/EventConsole.razor.cs @@ -0,0 +1,155 @@ +using DotBased.Logging; +using Manager.App.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.Virtualization; +using Microsoft.JSInterop; + +namespace Manager.App.Components.Application.System; + +public partial class EventConsole : ComponentBase +{ + private const int BatchDelayMs = 1000; + private List _serviceEvents = []; + private readonly List _batchBuffer = []; + private readonly SemaphoreSlim _batchLock = new(1, 1); + private ElementReference _consoleContainer; + private bool _autoScroll = true; + private CancellationTokenSource _cts = new(); + private TimeZoneInfo _timeZone = TimeZoneInfo.Local; + + [Parameter] + public List InitialEvents { get; set; } = []; + [Parameter] + public IAsyncEnumerable? AsyncEnumerable { get; set; } + + [Parameter] + public int Elevation { get; set; } + [Parameter] + public string? Class { get; set; } + [Parameter] + public string? Style { get; set; } + + protected override async Task OnInitializedAsync() + { + _serviceEvents.AddRange(InitialEvents); + var jsTimeZone = await JsRuntime.InvokeAsync("getUserTimeZone"); + if (!string.IsNullOrEmpty(jsTimeZone)) + { + _timeZone = TimeZoneInfo.FindSystemTimeZoneById(jsTimeZone); + } + _ = Task.Run(() => ReadEventStreamsAsync(_cts.Token)); + } + + private async Task ReadEventStreamsAsync(CancellationToken token) + { + if (AsyncEnumerable == null) + { + return; + } + + await foreach (var serviceEvent in AsyncEnumerable.WithCancellation(token)) + { + await _batchLock.WaitAsync(token); + try + { + _batchBuffer.Add(serviceEvent); + } + finally + { + _batchLock.Release(); + } + + _ = BatchUpdateUi(); + } + } + + private string GetLogClass(ServiceEvent serviceEvent) => + serviceEvent.Severity switch + { + LogSeverity.Info => "log-info", + LogSeverity.Warning => "log-warning", + LogSeverity.Error => "log-error", + LogSeverity.Debug => "log-debug", + LogSeverity.Trace => "log-trace", + LogSeverity.Fatal => "log-fatal", + LogSeverity.Verbose => "log-error", + _ => "log-info" + }; + + private DateTime _lastBatchUpdate = DateTime.MinValue; + private bool _updateScheduled; + + private async Task BatchUpdateUi() + { + if (_updateScheduled) return; + _updateScheduled = true; + + while (!_cts.Token.IsCancellationRequested) + { + var elapsed = (DateTime.UtcNow - _lastBatchUpdate).TotalMilliseconds; + if (elapsed < BatchDelayMs) + { + await Task.Delay(BatchDelayMs - (int)elapsed, _cts.Token); + } + + List batch; + await _batchLock.WaitAsync(); + try + { + if (_batchBuffer.Count == 0) continue; + batch = new List(_batchBuffer); + _batchBuffer.Clear(); + } + finally + { + _batchLock.Release(); + } + + _serviceEvents.AddRange(batch); + _lastBatchUpdate = DateTime.UtcNow; + + await InvokeAsync(StateHasChanged); + + if (_autoScroll) + { + await JsRuntime.InvokeVoidAsync("scrollToBottom", _consoleContainer); + } + + _updateScheduled = false; + break; + } + } + + private void OnUserScroll(WheelEventArgs e) + { + _ = UpdateAutoScroll(); + } + + private async Task UpdateAutoScroll() + { + if (_consoleContainer.Context != null) + { + var scrollInfo = await JsRuntime.InvokeAsync("getScrollInfo", _consoleContainer); + _autoScroll = scrollInfo.ScrollTop + scrollInfo.ClientHeight >= scrollInfo.ScrollHeight - 20; + } + } + + private ValueTask> VirtualizedItemsProvider(ItemsProviderRequest request) + { + var items = _serviceEvents.Skip(request.StartIndex).Take(request.Count); + return ValueTask.FromResult(new ItemsProviderResult(items, _serviceEvents.Count)); + } + + public void Dispose() + { + _batchLock.Dispose(); + } + + private class ScrollInfo + { + public double ScrollTop { get; set; } + public double ScrollHeight { get; set; } + public double ClientHeight { get; set; } + } +} \ No newline at end of file diff --git a/Manager.App/Components/Application/System/EventConsole.razor.css b/Manager.App/Components/Application/System/EventConsole.razor.css new file mode 100644 index 0000000..d4956fa --- /dev/null +++ b/Manager.App/Components/Application/System/EventConsole.razor.css @@ -0,0 +1,51 @@ +.console-container { + background-color: #1e1e1e; + color: #9c9898; + padding: 10px; + border-radius: 8px; + flex: 1; + overflow-y: auto; + font-family: monospace; +} + +.log-severity{ + display: inline-block; + width: 8ch; + text-align: left; + font-weight: bold; +} + +.log-line { + display: flex; + justify-content: start; + align-items: center; + white-space: nowrap; +} + +.log-info { + color: #3f6b81; +} + +.log-warning { + color: #f8f802; +} + +.log-error { + color: #f44747; +} + +.log-debug { + color: #e110ff; +} + +.log-trace { + color: #535353; +} + +.log-fatal { + color: #af1e1e; +} + +.log-verbose { + color: #8085ff; +} \ No newline at end of file diff --git a/Manager.App/Components/Pages/Services.razor b/Manager.App/Components/Pages/Services.razor index 770482d..3a3f96f 100644 --- a/Manager.App/Components/Pages/Services.razor +++ b/Manager.App/Components/Pages/Services.razor @@ -1,8 +1,9 @@ @page "/Services" @using Manager.App.Services.System +@using Manager.App.Components.Application.System @implements IDisposable -@inject BackgroundServiceManager ServiceManager +@inject BackgroundServiceRegistry ServiceRegistry Services @@ -37,47 +38,5 @@ - - - Service events - @($"{_serviceEvents.Count}/{VisibleEventCapacity} events") - -
- -
- @($"{serviceEvent.Date:HH:mm:ss} | {serviceEvent.Severity} | {serviceEvent.Source} - {serviceEvent.Message}") -
-
-
-
- - \ No newline at end of file + diff --git a/Manager.App/Components/Pages/Services.razor.cs b/Manager.App/Components/Pages/Services.razor.cs index a0bc3a1..aedcf9b 100644 --- a/Manager.App/Components/Pages/Services.razor.cs +++ b/Manager.App/Components/Pages/Services.razor.cs @@ -1,4 +1,3 @@ -using DotBased.Logging; using Manager.App.Extensions; using Manager.App.Services; using Microsoft.AspNetCore.Components; @@ -7,58 +6,31 @@ namespace Manager.App.Components.Pages; public partial class Services : ComponentBase { - private const int VisibleEventCapacity = 500; private string _searchText = ""; private List _backgroundServices = []; - - private List _serviceEvents = []; - private CancellationTokenSource _cts = new(); + private readonly CancellationTokenSource _cts = new(); protected override void OnInitialized() { - _backgroundServices = ServiceManager.GetServices(); - _ = Task.Run(() => ReadEventStreamsAsync(_cts.Token)); + _backgroundServices = ServiceRegistry.GetServices(); } private Func QuickFilter => x => string.IsNullOrWhiteSpace(_searchText) || $"{x.Name} {x.Description} {x.State} {x.ExecuteInterval}".Contains(_searchText); - private async Task ReadEventStreamsAsync(CancellationToken token) + private IAsyncEnumerable GetEventAsyncEnumerable() { - if (_backgroundServices.Count == 0) - { - return; - } - - var totalToGet = VisibleEventCapacity / _backgroundServices.Count; - _serviceEvents.AddRange(_backgroundServices.SelectMany(x => x.ProgressEvents.Items.TakeLast(totalToGet))); - var asyncEnumerators = _backgroundServices.Select(x => x.ProgressEvents.GetStreamAsync()); - await foreach (var serviceEvent in AsyncEnumerableExtensions.Merge(asyncEnumerators, token)) - { - if (!_serviceEvents.Contains(serviceEvent)) - { - _serviceEvents.Add(serviceEvent); - } - - if (_serviceEvents.Count > VisibleEventCapacity) - { - _serviceEvents.RemoveAt(0); - } - - await InvokeAsync(StateHasChanged); - } + return AsyncEnumerableExtensions.Merge(asyncEnumerators, CancellationToken.None); } - - private string GetLogClass(ServiceEvent serviceEvent) => - serviceEvent.Severity switch - { - LogSeverity.Info => "log-info", - LogSeverity.Warning => "log-warning", - LogSeverity.Error => "log-error", - _ => "log-info" - }; - + + private List GetInitialEvents() + { + var totalToGet = 1000 / _backgroundServices.Count; + var initial = _backgroundServices.SelectMany(x => x.ProgressEvents.Items.TakeLast(totalToGet)); + return initial.ToList(); + } + public void Dispose() { _cts.Cancel(); diff --git a/Manager.App/Components/_Imports.razor b/Manager.App/Components/_Imports.razor index 04bd2e4..18ce30f 100644 --- a/Manager.App/Components/_Imports.razor +++ b/Manager.App/Components/_Imports.razor @@ -1,6 +1,4 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/Manager.App/DependencyInjection.cs b/Manager.App/DependencyInjection.cs index 274d318..95b40df 100644 --- a/Manager.App/DependencyInjection.cs +++ b/Manager.App/DependencyInjection.cs @@ -24,10 +24,8 @@ public static class DependencyInjection logger.LogInformation("Setting library database to: {DbPath}", dbPath); options.UseSqlite($"Data Source={dbPath}"); }); - - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); + builder.RegisterExtendedBackgroundServices(); builder.Services.AddScoped(); } @@ -88,4 +86,20 @@ public static class DependencyInjection builder.Logging.SetMinimumLevel(isDevelopment ? LogLevel.Trace : LogLevel.Information); builder.Logging.AddDotBasedLoggerProvider(LogService.Options); } + + private static void RegisterExtendedBackgroundServices(this WebApplicationBuilder builder) + { + var assembly = typeof(Program).Assembly; + + foreach (var exBgService in assembly.GetTypes() + .Where(t => typeof(ExtendedBackgroundService).IsAssignableFrom(t) + && t is { IsClass: true, IsAbstract: false })) + { + builder.Services.AddSingleton(exBgService); + builder.Services.AddSingleton(typeof(ExtendedBackgroundService), sp => (ExtendedBackgroundService)sp.GetRequiredService(exBgService)); + builder.Services.AddSingleton(sp => (IHostedService)sp.GetRequiredService(exBgService)); + } + + builder.Services.AddSingleton(); + } } \ No newline at end of file diff --git a/Manager.App/Manager.App.csproj b/Manager.App/Manager.App.csproj index 9147f6a..6a36124 100644 --- a/Manager.App/Manager.App.csproj +++ b/Manager.App/Manager.App.csproj @@ -22,6 +22,7 @@ <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" /> <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" /> + <_ContentIncludedByDefault Remove="wwwroot\js\console.js" /> diff --git a/Manager.App/Services/ExtendedBackgroundService.cs b/Manager.App/Services/ExtendedBackgroundService.cs index c59361b..1a0e742 100644 --- a/Manager.App/Services/ExtendedBackgroundService.cs +++ b/Manager.App/Services/ExtendedBackgroundService.cs @@ -1,37 +1,27 @@ using DotBased.Logging; -using Manager.App.Services.System; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Manager.App.Services; -public abstract class ExtendedBackgroundService : BackgroundService +public abstract class ExtendedBackgroundService(string name, string description, ILogger logger, TimeSpan? executeInterval = null) + : BackgroundService { private TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly ILogger _logger; public ServiceState State { get; private set; } = ServiceState.Stopped; public CircularBuffer ProgressEvents { get; } = new(500); - public string Name { get; } - public string Description { get; set; } - public TimeSpan ExecuteInterval { get; set; } + public string Name { get; } = name; + public string Description { get; set; } = description; + public TimeSpan ExecuteInterval { get; set; } = executeInterval ?? TimeSpan.FromMinutes(1); - public ExtendedBackgroundService(string name, string description, ILogger logger, BackgroundServiceManager manager, TimeSpan? executeInterval = null) - { - Name = name; - Description = description; - _logger = logger; - manager.RegisterService(this); - ExecuteInterval = executeInterval ?? TimeSpan.FromMinutes(1); - } - protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) { State = ServiceState.Running; - _logger.LogInformation("Initializing background service: {ServiceName}", Name); + logger.LogInformation("Initializing background service: {ServiceName}", Name); await InitializeAsync(stoppingToken); try { - _logger.LogInformation("Running background service: {ServiceName}", Name); + logger.LogInformation("Running background service: {ServiceName}", Name); while (!stoppingToken.IsCancellationRequested) { if (State == ServiceState.Paused) @@ -49,10 +39,10 @@ public abstract class ExtendedBackgroundService : BackgroundService if (e is not OperationCanceledException) { State = ServiceState.Faulted; - _logger.LogError(e,"Background service {ServiceName} faulted!", Name); + logger.LogError(e,"Background service {ServiceName} faulted!", Name); throw; } - _logger.LogInformation(e,"Service {ServiceName} received cancellation", Name); + logger.LogInformation(e,"Service {ServiceName} received cancellation", Name); } finally { @@ -67,7 +57,7 @@ public abstract class ExtendedBackgroundService : BackgroundService if (State == ServiceState.Running) { State = ServiceState.Paused; - _logger.LogInformation("Pauses service: {ServiceName}", Name); + logger.LogInformation("Pauses service: {ServiceName}", Name); } } @@ -77,7 +67,7 @@ public abstract class ExtendedBackgroundService : BackgroundService { State = ServiceState.Running; _resumeSignal.TrySetResult(); - _logger.LogInformation("Resumed service: {ServiceName}", Name); + logger.LogInformation("Resumed service: {ServiceName}", Name); } } @@ -103,4 +93,4 @@ public enum ServiceState Paused } -public record struct ServiceEvent(string Source, string Message, DateTime Date, LogSeverity Severity); \ No newline at end of file +public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity); \ No newline at end of file diff --git a/Manager.App/Services/System/BackgroundServiceManager.cs b/Manager.App/Services/System/BackgroundServiceManager.cs deleted file mode 100644 index 0a5db99..0000000 --- a/Manager.App/Services/System/BackgroundServiceManager.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Manager.App.Services.System; - -public class BackgroundServiceManager -{ - private readonly HashSet _backgroundServices = []; - - public void RegisterService(ExtendedBackgroundService service) - { - _backgroundServices.Add(service); - } - - public List GetServices() - { - return _backgroundServices.ToList(); - } -} \ No newline at end of file diff --git a/Manager.App/Services/System/BackgroundServiceRegistry.cs b/Manager.App/Services/System/BackgroundServiceRegistry.cs new file mode 100644 index 0000000..cde57a4 --- /dev/null +++ b/Manager.App/Services/System/BackgroundServiceRegistry.cs @@ -0,0 +1,9 @@ +namespace Manager.App.Services.System; + +public class BackgroundServiceRegistry(IEnumerable backgroundServices) +{ + public List GetServices() + { + return backgroundServices.ToList(); + } +} \ No newline at end of file diff --git a/Manager.App/Services/System/ClientService.cs b/Manager.App/Services/System/ClientService.cs index 44226f3..89abdc7 100644 --- a/Manager.App/Services/System/ClientService.cs +++ b/Manager.App/Services/System/ClientService.cs @@ -7,8 +7,8 @@ using Manager.YouTube; namespace Manager.App.Services.System; -public class ClientService(IServiceScopeFactory scopeFactory, ILogger logger, BackgroundServiceManager serviceManager) - : ExtendedBackgroundService("ClientService", "Managing YouTube clients", logger, serviceManager, TimeSpan.FromMilliseconds(100)) +public class ClientService(IServiceScopeFactory scopeFactory, ILogger logger) + : ExtendedBackgroundService("ClientService", "Managing YouTube clients", logger, TimeSpan.FromMilliseconds(100)) { private readonly List _clients = []; private CancellationToken _cancellationToken; diff --git a/Manager.App/Services/System/TestService.cs b/Manager.App/Services/System/TestService.cs new file mode 100644 index 0000000..504a8e9 --- /dev/null +++ b/Manager.App/Services/System/TestService.cs @@ -0,0 +1,21 @@ +using DotBased.Logging; + +namespace Manager.App.Services.System; + +public class TestService(ILogger logger) : ExtendedBackgroundService("TestService", "Development service", logger, TimeSpan.FromMilliseconds(100)) +{ + protected override Task InitializeAsync(CancellationToken stoppingToken) + { + return Task.CompletedTask; + } + + protected override Task ExecuteServiceAsync(CancellationToken stoppingToken) + { + LogEvent("TestService"); + LogEvent($"Error {Guid.NewGuid()}", LogSeverity.Error); + LogEvent("Something went wrong!", LogSeverity.Warning); + LogEvent("Tracing.", LogSeverity.Trace); + LogEvent("Fatal error!", LogSeverity.Fatal); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Manager.App/wwwroot/js/eventConsole.js b/Manager.App/wwwroot/js/eventConsole.js new file mode 100644 index 0000000..4ad94b6 --- /dev/null +++ b/Manager.App/wwwroot/js/eventConsole.js @@ -0,0 +1,14 @@ +window.scrollToBottom = (element) => { + if (element) { + element.scroll({ top: element.scrollHeight, behavior: 'smooth' }); + } +}; + +window.getScrollInfo = (element) => { + if (!element) return null; + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight + }; +}; diff --git a/Manager.App/wwwroot/js/tz.js b/Manager.App/wwwroot/js/tz.js new file mode 100644 index 0000000..c1e1551 --- /dev/null +++ b/Manager.App/wwwroot/js/tz.js @@ -0,0 +1,3 @@ +function getUserTimeZone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +}