[CHANGE] Reworked event console

This commit is contained in:
max
2025-09-10 18:19:36 +02:00
parent ef6ca0ee07
commit 9be6f5be89
16 changed files with 326 additions and 131 deletions

View File

@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/> <base href="/"/>
<link rel="stylesheet" href="app.css"/> <link rel="stylesheet" href="app.css"/>
<link href="Manager.App.styles.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet"/>
<link href="_content/MudBlazor/MudBlazor.min.css?v=@Metadata.Version" rel="stylesheet"/> <link href="_content/MudBlazor/MudBlazor.min.css?v=@Metadata.Version" rel="stylesheet"/>
@@ -17,6 +18,8 @@
<Routes @rendermode="InteractiveServer"/> <Routes @rendermode="InteractiveServer"/>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js?v=@Metadata.Version"></script> <script src="_content/MudBlazor/MudBlazor.min.js?v=@Metadata.Version"></script>
<script src="js/tz.js"></script>
<script src="js/eventConsole.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,21 @@
@inject IJSRuntime JsRuntime
@implements IDisposable
<MudPaper Elevation="Elevation" Class="@Class" Style="@Style">
<MudStack Class="ml-2 mb-2" Spacing="2" Row>
<MudStack Spacing="1">
<MudText Typo="Typo.h5">Live service events</MudText>
<MudText Typo="Typo.caption">@($"{_serviceEvents.Count} events")</MudText>
</MudStack>
<MudSwitch @bind-Value="@_autoScroll">Auto-scroll</MudSwitch>
</MudStack>
<div @ref="@_consoleContainer" class="console-container" @onwheel="OnUserScroll">
<Virtualize ItemsProvider="VirtualizedItemsProvider" Context="serviceEvent">
<div class="log-line">
@TimeZoneInfo.ConvertTime(serviceEvent.DateUtc, _timeZone)&nbsp;
<span class="log-severity @GetLogClass(serviceEvent)">@serviceEvent.Severity</span>&nbsp;[<span style="color: #1565c0">@serviceEvent.Source</span>]&nbsp;
<span style="color: snow">@serviceEvent.Message</span>
</div>
</Virtualize>
</div>
</MudPaper>

View File

@@ -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<ServiceEvent> _serviceEvents = [];
private readonly List<ServiceEvent> _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<ServiceEvent> InitialEvents { get; set; } = [];
[Parameter]
public IAsyncEnumerable<ServiceEvent>? 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<string>("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<ServiceEvent> batch;
await _batchLock.WaitAsync();
try
{
if (_batchBuffer.Count == 0) continue;
batch = new List<ServiceEvent>(_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<ScrollInfo>("getScrollInfo", _consoleContainer);
_autoScroll = scrollInfo.ScrollTop + scrollInfo.ClientHeight >= scrollInfo.ScrollHeight - 20;
}
}
private ValueTask<ItemsProviderResult<ServiceEvent>> VirtualizedItemsProvider(ItemsProviderRequest request)
{
var items = _serviceEvents.Skip(request.StartIndex).Take(request.Count);
return ValueTask.FromResult(new ItemsProviderResult<ServiceEvent>(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; }
}
}

View File

@@ -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;
}

View File

@@ -1,8 +1,9 @@
@page "/Services" @page "/Services"
@using Manager.App.Services.System @using Manager.App.Services.System
@using Manager.App.Components.Application.System
@implements IDisposable @implements IDisposable
@inject BackgroundServiceManager ServiceManager @inject BackgroundServiceRegistry ServiceRegistry
<title>Services</title> <title>Services</title>
@@ -37,47 +38,5 @@
</PagerContent> </PagerContent>
</MudDataGrid> </MudDataGrid>
<MudPaper Elevation="0" Class="mt-3" Style="flex: 1; display: flex; flex-direction: column; min-height: 0;"> <EventConsole AsyncEnumerable="@GetEventAsyncEnumerable()" InitialEvents="@GetInitialEvents()"
<MudStack Class="ml-2 mb-2" Spacing="1"> Elevation="0" Class="mt-3" Style="flex: 1; display: flex; flex-direction: column; min-height: 0;"/>
<MudText Typo="Typo.h5">Service events</MudText>
<MudText Typo="Typo.caption">@($"{_serviceEvents.Count}/{VisibleEventCapacity} events")</MudText>
</MudStack>
<div class="console-container">
<Virtualize Items="_serviceEvents" Context="serviceEvent">
<div class="log-line">
@($"{serviceEvent.Date:HH:mm:ss} | {serviceEvent.Severity} | {serviceEvent.Source} - {serviceEvent.Message}")
</div>
</Virtualize>
</div>
</MudPaper>
<style>
.console-container {
background-color: #1e1e1e;
color: #9c9898;
padding: 10px;
border-radius: 8px;
flex: 1;
overflow-y: auto;
font-family: monospace;
}
.log-line {
display: flex;
justify-content: start;
align-items: center;
gap: 0.25rem;
}
.log-info {
color: #9cdcfe;
}
.log-warning {
color: #dcdcaa;
}
.log-error {
color: #f44747;
}
</style>

View File

@@ -1,4 +1,3 @@
using DotBased.Logging;
using Manager.App.Extensions; using Manager.App.Extensions;
using Manager.App.Services; using Manager.App.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -7,58 +6,31 @@ namespace Manager.App.Components.Pages;
public partial class Services : ComponentBase public partial class Services : ComponentBase
{ {
private const int VisibleEventCapacity = 500;
private string _searchText = ""; private string _searchText = "";
private List<ExtendedBackgroundService> _backgroundServices = []; private List<ExtendedBackgroundService> _backgroundServices = [];
private readonly CancellationTokenSource _cts = new();
private List<ServiceEvent> _serviceEvents = [];
private CancellationTokenSource _cts = new();
protected override void OnInitialized() protected override void OnInitialized()
{ {
_backgroundServices = ServiceManager.GetServices(); _backgroundServices = ServiceRegistry.GetServices();
_ = Task.Run(() => ReadEventStreamsAsync(_cts.Token));
} }
private Func<ExtendedBackgroundService, bool> QuickFilter private Func<ExtendedBackgroundService, bool> QuickFilter
=> x => string.IsNullOrWhiteSpace(_searchText) || $"{x.Name} {x.Description} {x.State} {x.ExecuteInterval}".Contains(_searchText); => x => string.IsNullOrWhiteSpace(_searchText) || $"{x.Name} {x.Description} {x.State} {x.ExecuteInterval}".Contains(_searchText);
private async Task ReadEventStreamsAsync(CancellationToken token) private IAsyncEnumerable<ServiceEvent> 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()); var asyncEnumerators = _backgroundServices.Select(x => x.ProgressEvents.GetStreamAsync());
await foreach (var serviceEvent in AsyncEnumerableExtensions.Merge(asyncEnumerators, token)) return AsyncEnumerableExtensions.Merge(asyncEnumerators, CancellationToken.None);
{
if (!_serviceEvents.Contains(serviceEvent))
{
_serviceEvents.Add(serviceEvent);
}
if (_serviceEvents.Count > VisibleEventCapacity)
{
_serviceEvents.RemoveAt(0);
}
await InvokeAsync(StateHasChanged);
}
} }
private string GetLogClass(ServiceEvent serviceEvent) => private List<ServiceEvent> GetInitialEvents()
serviceEvent.Severity switch {
{ var totalToGet = 1000 / _backgroundServices.Count;
LogSeverity.Info => "log-info", var initial = _backgroundServices.SelectMany(x => x.ProgressEvents.Items.TakeLast(totalToGet));
LogSeverity.Warning => "log-warning", return initial.ToList();
LogSeverity.Error => "log-error", }
_ => "log-info"
};
public void Dispose() public void Dispose()
{ {
_cts.Cancel(); _cts.Cancel();

View File

@@ -1,6 +1,4 @@
@using System.Net.Http @using Microsoft.AspNetCore.Components.Forms
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode

View File

@@ -24,10 +24,8 @@ public static class DependencyInjection
logger.LogInformation("Setting library database to: {DbPath}", dbPath); logger.LogInformation("Setting library database to: {DbPath}", dbPath);
options.UseSqlite($"Data Source={dbPath}"); options.UseSqlite($"Data Source={dbPath}");
}); });
builder.Services.AddSingleton<BackgroundServiceManager>(); builder.RegisterExtendedBackgroundServices();
builder.Services.AddHostedService<ClientService>();
builder.Services.AddScoped<ILibraryService, LibraryService>(); builder.Services.AddScoped<ILibraryService, LibraryService>();
} }
@@ -88,4 +86,20 @@ public static class DependencyInjection
builder.Logging.SetMinimumLevel(isDevelopment ? LogLevel.Trace : LogLevel.Information); builder.Logging.SetMinimumLevel(isDevelopment ? LogLevel.Trace : LogLevel.Information);
builder.Logging.AddDotBasedLoggerProvider(LogService.Options); 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<IHostedService>(sp => (IHostedService)sp.GetRequiredService(exBgService));
}
builder.Services.AddSingleton<BackgroundServiceRegistry>();
}
} }

View File

@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" /> <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" /> <_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
<_ContentIncludedByDefault Remove="wwwroot\js\console.js" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,37 +1,27 @@
using DotBased.Logging; using DotBased.Logging;
using Manager.App.Services.System;
using ILogger = Microsoft.Extensions.Logging.ILogger; using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Manager.App.Services; 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 TaskCompletionSource _resumeSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly ILogger _logger;
public ServiceState State { get; private set; } = ServiceState.Stopped; public ServiceState State { get; private set; } = ServiceState.Stopped;
public CircularBuffer<ServiceEvent> ProgressEvents { get; } = new(500); public CircularBuffer<ServiceEvent> ProgressEvents { get; } = new(500);
public string Name { get; } public string Name { get; } = name;
public string Description { get; set; } public string Description { get; set; } = description;
public TimeSpan ExecuteInterval { get; set; } 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) protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
State = ServiceState.Running; State = ServiceState.Running;
_logger.LogInformation("Initializing background service: {ServiceName}", Name); logger.LogInformation("Initializing background service: {ServiceName}", Name);
await InitializeAsync(stoppingToken); await InitializeAsync(stoppingToken);
try try
{ {
_logger.LogInformation("Running background service: {ServiceName}", Name); logger.LogInformation("Running background service: {ServiceName}", Name);
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
if (State == ServiceState.Paused) if (State == ServiceState.Paused)
@@ -49,10 +39,10 @@ public abstract class ExtendedBackgroundService : BackgroundService
if (e is not OperationCanceledException) if (e is not OperationCanceledException)
{ {
State = ServiceState.Faulted; State = ServiceState.Faulted;
_logger.LogError(e,"Background service {ServiceName} faulted!", Name); logger.LogError(e,"Background service {ServiceName} faulted!", Name);
throw; throw;
} }
_logger.LogInformation(e,"Service {ServiceName} received cancellation", Name); logger.LogInformation(e,"Service {ServiceName} received cancellation", Name);
} }
finally finally
{ {
@@ -67,7 +57,7 @@ public abstract class ExtendedBackgroundService : BackgroundService
if (State == ServiceState.Running) if (State == ServiceState.Running)
{ {
State = ServiceState.Paused; 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; State = ServiceState.Running;
_resumeSignal.TrySetResult(); _resumeSignal.TrySetResult();
_logger.LogInformation("Resumed service: {ServiceName}", Name); logger.LogInformation("Resumed service: {ServiceName}", Name);
} }
} }
@@ -103,4 +93,4 @@ public enum ServiceState
Paused Paused
} }
public record struct ServiceEvent(string Source, string Message, DateTime Date, LogSeverity Severity); public record struct ServiceEvent(string Source, string Message, DateTime DateUtc, LogSeverity Severity);

View File

@@ -1,16 +0,0 @@
namespace Manager.App.Services.System;
public class BackgroundServiceManager
{
private readonly HashSet<ExtendedBackgroundService> _backgroundServices = [];
public void RegisterService(ExtendedBackgroundService service)
{
_backgroundServices.Add(service);
}
public List<ExtendedBackgroundService> GetServices()
{
return _backgroundServices.ToList();
}
}

View File

@@ -0,0 +1,9 @@
namespace Manager.App.Services.System;
public class BackgroundServiceRegistry(IEnumerable<ExtendedBackgroundService> backgroundServices)
{
public List<ExtendedBackgroundService> GetServices()
{
return backgroundServices.ToList();
}
}

View File

@@ -7,8 +7,8 @@ using Manager.YouTube;
namespace Manager.App.Services.System; namespace Manager.App.Services.System;
public class ClientService(IServiceScopeFactory scopeFactory, ILogger<ClientService> logger, BackgroundServiceManager serviceManager) public class ClientService(IServiceScopeFactory scopeFactory, ILogger<ClientService> logger)
: ExtendedBackgroundService("ClientService", "Managing YouTube clients", logger, serviceManager, TimeSpan.FromMilliseconds(100)) : ExtendedBackgroundService("ClientService", "Managing YouTube clients", logger, TimeSpan.FromMilliseconds(100))
{ {
private readonly List<YouTubeClient> _clients = []; private readonly List<YouTubeClient> _clients = [];
private CancellationToken _cancellationToken; private CancellationToken _cancellationToken;

View File

@@ -0,0 +1,21 @@
using DotBased.Logging;
namespace Manager.App.Services.System;
public class TestService(ILogger<TestService> 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;
}
}

View File

@@ -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
};
};

View File

@@ -0,0 +1,3 @@
function getUserTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}