mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: add server logs functionality with role-based access and system channel management
- Introduced a new migration to add the IsSystem column to the Channels table. - Updated the DbContext model snapshot to reflect the new IsSystem property. - Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion. - Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles. - Created ServerLogsSink to queue log events for streaming to the live log room. - Developed ServerLogsStreamService to stream log events to clients in real-time. - Added configuration options for server logs in appsettings. - Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Server.Config;
|
||||
using EchoHub.Server.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace EchoHub.Server.Services.ServerLogs;
|
||||
|
||||
/// <summary>
|
||||
/// Streams queued log events to the live log room as ephemeral messages: SignalR only (the
|
||||
/// IRC gateway never sees them) and no database rows. Ensures the room exists before
|
||||
/// streaming, recreating it on the fly if it was deleted.
|
||||
///
|
||||
/// This class must never log from its streaming path — its namespace is excluded by
|
||||
/// <see cref="ServerLogsSink"/> as a second line of defense, but the primary rule is simply
|
||||
/// not to log per event, otherwise every streamed line would spawn another.
|
||||
/// </summary>
|
||||
public sealed class ServerLogsStreamService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan EnsureInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly ServerLogsSink _sink;
|
||||
private readonly ServerLogsOptions _options;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
|
||||
private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue;
|
||||
|
||||
// Resolved lazily: the hub context isn't available while hosted services are constructed.
|
||||
private IHubContext<ChatHub, IEchoHubClient> HubContext
|
||||
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
|
||||
|
||||
public ServerLogsStreamService(
|
||||
ServerLogsSink sink,
|
||||
ServerLogsOptions options,
|
||||
IChannelService channelService,
|
||||
IMessageEncryptionService encryption,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
_sink = sink;
|
||||
_options = options;
|
||||
_channelService = channelService;
|
||||
_encryption = encryption;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Make the room exist from server start, not only once the first event arrives.
|
||||
await TryEnsureRoomAsync();
|
||||
|
||||
await foreach (var logEvent in _sink.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await TryEnsureRoomAsync();
|
||||
|
||||
var roomName = _options.NormalizedRoomName;
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(),
|
||||
_encryption.Encrypt(Format(logEvent)),
|
||||
ServerLogsService.SenderName,
|
||||
null,
|
||||
roomName,
|
||||
logEvent.Timestamp);
|
||||
|
||||
await HubContext.Clients.Group(roomName).ReceiveMessage(message);
|
||||
}
|
||||
catch when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Swallow: logging here would re-enter the pipeline.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates the log room if it disappeared, at most once per <see cref="EnsureInterval"/>.
|
||||
/// </summary>
|
||||
private async Task TryEnsureRoomAsync()
|
||||
{
|
||||
if (DateTimeOffset.UtcNow - _lastEnsure < EnsureInterval)
|
||||
return;
|
||||
|
||||
_lastEnsure = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
await _channelService.EnsureSystemChannelAsync(_options.NormalizedRoomName, ServerLogsService.RoomTopic);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Retried on the next interval; events streamed meanwhile just go to no group members.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.</summary>
|
||||
public static string Format(LogEvent logEvent)
|
||||
{
|
||||
var builder = new StringBuilder("[").Append(ShortLevel(logEvent.Level)).Append("] ")
|
||||
.Append(logEvent.RenderMessage(CultureInfo.InvariantCulture));
|
||||
|
||||
if (logEvent.Exception is not null)
|
||||
builder.Append('\n').Append(logEvent.Exception);
|
||||
|
||||
if (builder.Length > HubConstants.MaxMessageLength)
|
||||
{
|
||||
builder.Length = HubConstants.MaxMessageLength - 1;
|
||||
builder.Append('…');
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string ShortLevel(LogEventLevel level) => level switch
|
||||
{
|
||||
LogEventLevel.Verbose => "VRB",
|
||||
LogEventLevel.Debug => "DBG",
|
||||
LogEventLevel.Information => "INF",
|
||||
LogEventLevel.Warning => "WRN",
|
||||
LogEventLevel.Error => "ERR",
|
||||
LogEventLevel.Fatal => "FTL",
|
||||
_ => level.ToString().ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user