diff --git a/.env.example b/.env.example
index 825ec91..4c6b8ee 100644
--- a/.env.example
+++ b/.env.example
@@ -75,3 +75,19 @@ Irc__Enabled=false
# ── Logging ──────────────────────────────────────────────────────────
# Serilog__MinimumLevel__Default=Information
+
+# ── Server logs room ─────────────────────────────────────────────────
+# Read-only system channel that live-streams Serilog events to Mod+ users.
+# ServerLogs__Enabled=true
+# ServerLogs__RoomName=server-logs
+# ServerLogs__MinRole=Mod
+# ServerLogs__MinLevel=Information
+# ServerLogs__BacklogLines=100
+# ServerLogs__LogDirectory=logs
+# ServerLogs__LogFilePattern=echohub-server-*.log
+
+# ── Periodic stats report ────────────────────────────────────────────
+# Aggregate activity snapshot logged as JSON and persisted to the DB.
+# Stats__Enabled=true
+# Stats__IntervalHours=6
+# Stats__RetentionDays=90
diff --git a/src/EchoHub.Core/Models/ServerStatsReport.cs b/src/EchoHub.Core/Models/ServerStatsReport.cs
new file mode 100644
index 0000000..7a855af
--- /dev/null
+++ b/src/EchoHub.Core/Models/ServerStatsReport.cs
@@ -0,0 +1,62 @@
+namespace EchoHub.Core.Models;
+
+///
+/// A snapshot of server activity over one reporting window, produced periodically by the
+/// stats-report background job. Each report is logged as pretty-printed JSON and persisted
+/// for historical trend analysis. The window is "since the previous report" (or since startup
+/// for the first report of a run).
+///
+public class ServerStatsReport
+{
+ public Guid Id { get; set; }
+
+ /// When this report was generated (equals ).
+ public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ /// Start of the reporting window.
+ public DateTimeOffset PeriodStart { get; set; }
+
+ /// End of the reporting window.
+ public DateTimeOffset PeriodEnd { get; set; }
+
+ /// Length of the reporting window in hours.
+ public double WindowHours { get; set; }
+
+ // ── Activity during the window ──────────────────────────────────────────
+ /// Messages sent during the window.
+ public int MessagesSent { get; set; }
+
+ /// Attachments (files/images/audio) uploaded during the window.
+ public int FilesUploaded { get; set; }
+
+ /// Total bytes across all attachments uploaded during the window.
+ public long BytesUploaded { get; set; }
+
+ /// Accounts registered during the window ("new members joined").
+ public int NewMembers { get; set; }
+
+ /// Distinct users who sent at least one message during the window.
+ public int ActiveMembers { get; set; }
+
+ /// Session connects during the window (per-connection, across SignalR + IRC).
+ public int Connections { get; set; }
+
+ /// Session disconnects during the window ("members left" sessions).
+ public int Disconnections { get; set; }
+
+ /// Users kicked during the window.
+ public int Kicks { get; set; }
+
+ /// Users banned during the window.
+ public int Bans { get; set; }
+
+ // ── Point-in-time totals at window end ──────────────────────────────────
+ /// Total registered (unique) members at window end.
+ public int TotalMembers { get; set; }
+
+ /// Distinct users online at window end.
+ public int OnlineNow { get; set; }
+
+ /// Peak distinct users online observed during the window.
+ public int PeakOnline { get; set; }
+}
diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs
index 357125b..f4554b3 100644
--- a/src/EchoHub.Server.Irc/IrcGatewayService.cs
+++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs
@@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService
var connection = new IrcClientConnection(tcpClient, stream);
_connections[connection.ConnectionId] = connection;
- _logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
+ _logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId);
IChatService? chatService = null;
@@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService
_connections.TryRemove(connection.ConnectionId, out _);
await connection.DisposeAsync();
- _logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
+ _logger.LogDebug("IRC client {Id} ({Nick}) disconnected",
connection.ConnectionId, connection.Nickname ?? "unregistered");
}
}
diff --git a/src/EchoHub.Server/Config/StatsOptions.cs b/src/EchoHub.Server/Config/StatsOptions.cs
new file mode 100644
index 0000000..09914a4
--- /dev/null
+++ b/src/EchoHub.Server/Config/StatsOptions.cs
@@ -0,0 +1,21 @@
+namespace EchoHub.Server.Config;
+
+///
+/// Periodic server-stats report settings, bound from the "Stats" config section (env override:
+/// Stats__Enabled etc.). When enabled, a background job periodically snapshots server
+/// activity, logs it as pretty-printed JSON, and persists it to the database.
+///
+public sealed class StatsOptions
+{
+ /// Master switch for the periodic stats report job.
+ public bool Enabled { get; set; } = true;
+
+ /// How often a report is generated, in hours. Default: every 6 hours.
+ public double IntervalHours { get; set; } = 6;
+
+ ///
+ /// How long persisted reports are kept before being pruned, in days. Set to 0 to keep
+ /// reports indefinitely. Default: 90 days.
+ ///
+ public int RetentionDays { get; set; } = 90;
+}
diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs
index 0765e10..096f87a 100644
--- a/src/EchoHub.Server/Controllers/ChannelsController.cs
+++ b/src/EchoHub.Server/Controllers/ChannelsController.cs
@@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase
private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption;
private readonly UploadLimits _uploadLimits;
+ private readonly ILogger _logger;
public ChannelsController(
IChannelService channelService,
@@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase
IHttpClientFactory httpClientFactory,
IChatService chatService,
IMessageEncryptionService encryption,
- UploadLimits uploadLimits)
+ UploadLimits uploadLimits,
+ ILogger logger)
{
_channelService = channelService;
_db = db;
@@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase
_chatService = chatService;
_encryption = encryption;
_uploadLimits = uploadLimits;
+ _logger = logger;
}
[HttpGet]
@@ -289,6 +292,12 @@ public class ChannelsController : ControllerBase
});
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
_encryption.EncryptNullable(previewPlain)));
+
+ // Filename is client-encrypted ciphertext in E2E rooms — never log it there.
+ var loggedName = channelDto.IsEncrypted ? "[encrypted]" : file.FileName;
+ _logger.LogInformation(
+ "{User} uploaded {Kind} '{FileName}' ({Size} bytes) to '{Channel}': {Url}",
+ usernameClaim, kind, loggedName, file.Length, channelName, url);
}
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
@@ -446,6 +455,10 @@ public class ChannelsController : ControllerBase
_db.Messages.Add(message);
await _db.SaveChangesAsync();
+ _logger.LogInformation(
+ "{User} uploaded Image '{FileName}' ({Size} bytes) from URL to '{Channel}': {Url} (source: {Source})",
+ usernameClaim, fileName, imageBytes.Length, channelName, attachmentUrl, request.Url);
+
var messageDto = new MessageDto(
message.Id,
_encryption.Encrypt(string.Empty),
diff --git a/src/EchoHub.Server/Controllers/ModerationController.cs b/src/EchoHub.Server/Controllers/ModerationController.cs
index d6de0d4..b1c818f 100644
--- a/src/EchoHub.Server/Controllers/ModerationController.cs
+++ b/src/EchoHub.Server/Controllers/ModerationController.cs
@@ -4,6 +4,7 @@ using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
+using EchoHub.Server.Services.Stats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -22,19 +23,25 @@ public class ModerationController : ControllerBase
private readonly PresenceTracker _presenceTracker;
private readonly FileStorageService _fileStorage;
private readonly IEnumerable _broadcasters;
+ private readonly ServerStatsCollector _statsCollector;
+ private readonly ILogger _logger;
public ModerationController(
EchoHubDbContext db,
IChatService chatService,
PresenceTracker presenceTracker,
FileStorageService fileStorage,
- IEnumerable broadcasters)
+ IEnumerable broadcasters,
+ ServerStatsCollector statsCollector,
+ ILogger logger)
{
_db = db;
_chatService = chatService;
_presenceTracker = presenceTracker;
_fileStorage = fileStorage;
_broadcasters = broadcasters;
+ _statsCollector = statsCollector;
+ _logger = logger;
}
[HttpPost("role")]
@@ -56,9 +63,14 @@ public class ModerationController : ControllerBase
if (request.Role >= caller!.Role)
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
+ var previousRole = target.Role;
target.Role = request.Role;
await _db.SaveChangesAsync();
+ _logger.LogInformation(
+ "Role change: {Actor} set {Target} from {OldRole} to {NewRole}",
+ caller!.Username, target.Username, previousRole, request.Role);
+
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
}
@@ -86,6 +98,11 @@ public class ModerationController : ControllerBase
var reason = request?.Reason ?? "You have been kicked from the server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason);
+ _statsCollector.RecordKick();
+ _logger.LogInformation(
+ "Kick: {Actor} kicked {Target} (reason: {Reason})",
+ caller!.Username, target.Username, request?.Reason ?? "none");
+
return Ok(new { Message = $"{target.Username} has been kicked." });
}
@@ -111,13 +128,18 @@ public class ModerationController : ControllerBase
var reason = request?.Reason ?? "You have been banned from this server.";
await ForceDisconnectAndCleanupAsync(target.Username, reason);
+ _statsCollector.RecordBan();
+ _logger.LogWarning(
+ "Ban: {Actor} banned {Target} (reason: {Reason})",
+ caller!.Username, target.Username, request?.Reason ?? "none");
+
return Ok(new { Message = $"{target.Username} has been banned." });
}
[HttpPost("unban/{username}")]
public async Task UnbanUser(string username)
{
- var (_, error) = await GetCallerAsync(ServerRole.Admin);
+ var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
@@ -127,6 +149,8 @@ public class ModerationController : ControllerBase
target.IsBanned = false;
await _db.SaveChangesAsync();
+ _logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username);
+
return Ok(new { Message = $"{target.Username} has been unbanned." });
}
@@ -149,6 +173,12 @@ public class ModerationController : ControllerBase
: null;
await _db.SaveChangesAsync();
+ _logger.LogInformation(
+ "Mute: {Actor} muted {Target} ({Duration}, reason: {Reason})",
+ caller!.Username, target.Username,
+ request?.DurationMinutes is > 0 ? $"{request.DurationMinutes}m" : "indefinite",
+ request?.Reason ?? "none");
+
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
}
@@ -156,7 +186,7 @@ public class ModerationController : ControllerBase
[HttpPost("unmute/{username}")]
public async Task UnmuteUser(string username)
{
- var (_, error) = await GetCallerAsync(ServerRole.Mod);
+ var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
@@ -167,6 +197,8 @@ public class ModerationController : ControllerBase
target.MutedUntil = null;
await _db.SaveChangesAsync();
+ _logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username);
+
return Ok(new { Message = $"{target.Username} has been unmuted." });
}
@@ -219,13 +251,19 @@ public class ModerationController : ControllerBase
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
+ // Only moderator removals of another user's message are noteworthy; self-deletes are routine.
+ if (!isOwnMessage)
+ _logger.LogInformation(
+ "Message removed: {Actor} deleted {Author}'s message {MessageId} in '{Channel}'",
+ caller.Username, message.SenderUsername, messageId, channelName);
+
return Ok(new { Message = "Message deleted." });
}
[HttpDelete("channels/{channel}/nuke")]
public async Task NukeChannel(string channel)
{
- var (_, error) = await GetCallerAsync(ServerRole.Mod);
+ var (caller, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
var channelName = channel.ToLowerInvariant().Trim();
@@ -251,6 +289,10 @@ public class ModerationController : ControllerBase
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
+ _logger.LogWarning(
+ "Channel nuked: {Actor} cleared {Count} messages from '{Channel}'",
+ caller!.Username, messages.Count, channelName);
+
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
}
diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs
index 0e1cc06..7c8a4a0 100644
--- a/src/EchoHub.Server/Data/EchoHubDbContext.cs
+++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs
@@ -14,6 +14,7 @@ public class EchoHubDbContext : DbContext
public DbSet RefreshTokens => Set();
public DbSet ChannelMemberships => Set();
public DbSet InviteCodes => Set();
+ public DbSet ServerStatsReports => Set();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -107,6 +108,12 @@ public class EchoHubDbContext : DbContext
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
});
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(r => r.Id);
+ entity.HasIndex(r => r.GeneratedAt);
+ });
+
modelBuilder.Entity(entity =>
{
entity.HasKey(r => r.Id);
diff --git a/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs
new file mode 100644
index 0000000..c28709e
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.Designer.cs
@@ -0,0 +1,437 @@
+//
+using System;
+using EchoHub.Server.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace EchoHub.Server.Data.Migrations
+{
+ [DbContext(typeof(EchoHubDbContext))]
+ [Migration("20260717200332_AddServerStatsReports")]
+ partial class AddServerStatsReports
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
+
+ modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AsciiPreview")
+ .HasMaxLength(64000)
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("FileSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("TEXT");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MessageId");
+
+ b.ToTable("Attachments");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("EncryptionSalt")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("IsPublic")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsSystem")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Topic")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("WrappedRoomKey")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("JoinedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "ChannelId");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ChannelMemberships");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedByUsername")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MaxUses")
+ .HasColumnType("INTEGER");
+
+ b.Property("UseCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("InviteCodes");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AttachmentFileName")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("AttachmentFileSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("AttachmentUrl")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(16000)
+ .HasColumnType("TEXT");
+
+ b.Property("EmbedJson")
+ .HasMaxLength(32000)
+ .HasColumnType("TEXT");
+
+ b.Property("ReplyToMessageId")
+ .HasColumnType("TEXT");
+
+ b.Property("SenderUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("SenderUsername")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("SentAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("SentAt");
+
+ b.ToTable("Messages");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("Bans")
+ .HasColumnType("INTEGER");
+
+ b.Property("BytesUploaded")
+ .HasColumnType("INTEGER");
+
+ b.Property("Connections")
+ .HasColumnType("INTEGER");
+
+ b.Property("Disconnections")
+ .HasColumnType("INTEGER");
+
+ b.Property("FilesUploaded")
+ .HasColumnType("INTEGER");
+
+ b.Property("GeneratedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessagesSent")
+ .HasColumnType("INTEGER");
+
+ b.Property("NewMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("OnlineNow")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeakOnline")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodStart")
+ .HasColumnType("INTEGER");
+
+ b.Property("TotalMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("WindowHours")
+ .HasColumnType("REAL");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GeneratedAt");
+
+ b.ToTable("ServerStatsReports");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AvatarAscii")
+ .HasMaxLength(10000)
+ .HasColumnType("TEXT");
+
+ b.Property("Bio")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DisplayName")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("IsBanned")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsMuted")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MutedUntil")
+ .HasColumnType("INTEGER");
+
+ b.Property("NicknameColor")
+ .HasMaxLength(7)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("StatusMessage")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Message", "Message")
+ .WithMany("Attachments")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Message");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", null)
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("EchoHub.Core.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.Channel", "Channel")
+ .WithMany("Messages")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
+ {
+ b.HasOne("EchoHub.Core.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
+ {
+ b.Navigation("Attachments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs
new file mode 100644
index 0000000..104a5ac
--- /dev/null
+++ b/src/EchoHub.Server/Data/Migrations/20260717200332_AddServerStatsReports.cs
@@ -0,0 +1,54 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace EchoHub.Server.Data.Migrations
+{
+ ///
+ public partial class AddServerStatsReports : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "ServerStatsReports",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ GeneratedAt = table.Column(type: "INTEGER", nullable: false),
+ PeriodStart = table.Column(type: "INTEGER", nullable: false),
+ PeriodEnd = table.Column(type: "INTEGER", nullable: false),
+ WindowHours = table.Column(type: "REAL", nullable: false),
+ MessagesSent = table.Column(type: "INTEGER", nullable: false),
+ FilesUploaded = table.Column(type: "INTEGER", nullable: false),
+ BytesUploaded = table.Column(type: "INTEGER", nullable: false),
+ NewMembers = table.Column(type: "INTEGER", nullable: false),
+ ActiveMembers = table.Column(type: "INTEGER", nullable: false),
+ Connections = table.Column(type: "INTEGER", nullable: false),
+ Disconnections = table.Column(type: "INTEGER", nullable: false),
+ Kicks = table.Column(type: "INTEGER", nullable: false),
+ Bans = table.Column(type: "INTEGER", nullable: false),
+ TotalMembers = table.Column(type: "INTEGER", nullable: false),
+ OnlineNow = table.Column(type: "INTEGER", nullable: false),
+ PeakOnline = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ServerStatsReports", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ServerStatsReports_GeneratedAt",
+ table: "ServerStatsReports",
+ column: "GeneratedAt");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ServerStatsReports");
+ }
+ }
+}
diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
index 8e02d18..5756c79 100644
--- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
+++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs
@@ -246,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("RefreshTokens");
});
+ modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("Bans")
+ .HasColumnType("INTEGER");
+
+ b.Property("BytesUploaded")
+ .HasColumnType("INTEGER");
+
+ b.Property("Connections")
+ .HasColumnType("INTEGER");
+
+ b.Property("Disconnections")
+ .HasColumnType("INTEGER");
+
+ b.Property("FilesUploaded")
+ .HasColumnType("INTEGER");
+
+ b.Property("GeneratedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kicks")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessagesSent")
+ .HasColumnType("INTEGER");
+
+ b.Property("NewMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("OnlineNow")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeakOnline")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("INTEGER");
+
+ b.Property("PeriodStart")
+ .HasColumnType("INTEGER");
+
+ b.Property("TotalMembers")
+ .HasColumnType("INTEGER");
+
+ b.Property("WindowHours")
+ .HasColumnType("REAL");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GeneratedAt");
+
+ b.ToTable("ServerStatsReports");
+ });
+
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property("Id")
diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs
index 27b9de9..db8b117 100644
--- a/src/EchoHub.Server/Program.cs
+++ b/src/EchoHub.Server/Program.cs
@@ -11,6 +11,7 @@ using EchoHub.Server.Hubs;
using EchoHub.Server.Irc;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
+using EchoHub.Server.Services.Stats;
using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
@@ -142,6 +143,13 @@ while (true)
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+ // ── Periodic stats report (admin-configurable via the "Stats" section) ─
+ var statsOptions = builder.Configuration.GetSection("Stats").Get() ?? new StatsOptions();
+ builder.Services.AddSingleton(statsOptions);
+ builder.Services.AddSingleton();
+ if (statsOptions.Enabled)
+ builder.Services.AddHostedService();
+
// Live server-log streaming (only when the sink is active)
if (serverLogsSink is not null)
builder.Services.AddHostedService();
diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs
index 9d29b47..dd5724e 100644
--- a/src/EchoHub.Server/Services/ChatService.cs
+++ b/src/EchoHub.Server/Services/ChatService.cs
@@ -6,6 +6,7 @@ using EchoHub.Core.Models;
using EchoHub.Core.Security;
using EchoHub.Server.Data;
using EchoHub.Server.Services.ServerLogs;
+using EchoHub.Server.Services.Stats;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -23,6 +24,7 @@ public class ChatService : IChatService
private readonly FileStorageService _fileStorage;
private readonly SpamGuard _spamGuard;
private readonly ServerLogsService _serverLogs;
+ private readonly ServerStatsCollector _statsCollector;
private readonly ILogger _logger;
public ChatService(
@@ -35,6 +37,7 @@ public class ChatService : IChatService
FileStorageService fileStorage,
SpamGuard spamGuard,
ServerLogsService serverLogs,
+ ServerStatsCollector statsCollector,
ILogger logger)
{
_scopeFactory = scopeFactory;
@@ -46,12 +49,14 @@ public class ChatService : IChatService
_fileStorage = fileStorage;
_spamGuard = spamGuard;
_serverLogs = serverLogs;
+ _statsCollector = statsCollector;
_logger = logger;
}
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
{
_presenceTracker.UserConnected(connectionId, userId, username);
+ _statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount());
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
@@ -64,7 +69,9 @@ public class ChatService : IChatService
await db.SaveChangesAsync();
}
- _logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
+ // Debug-level: connect/disconnect churn is high-volume on a busy server. Aggregate
+ // counts land in the periodic stats report instead.
+ _logger.LogDebug("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
}
public async Task UserDisconnectedAsync(string connectionId)
@@ -75,6 +82,7 @@ public class ChatService : IChatService
: [];
var username = _presenceTracker.UserDisconnected(connectionId);
+ _statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount());
if (username is not null && !_presenceTracker.IsOnline(username))
{
@@ -100,7 +108,7 @@ public class ChatService : IChatService
}
}
- _logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
+ _logger.LogDebug("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
return username;
}
@@ -165,7 +173,7 @@ public class ChatService : IChatService
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
}
- _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
+ _logger.LogDebug("{User} joined channel '{Channel}'", username, channelName);
}
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
@@ -177,7 +185,7 @@ public class ChatService : IChatService
channelName = channelName.ToLowerInvariant().Trim();
_presenceTracker.LeaveChannel(username, channelName);
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
- _logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
+ _logger.LogDebug("{User} left channel '{Channel}'", username, channelName);
}
public async Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
diff --git a/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs b/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs
new file mode 100644
index 0000000..e94cc64
--- /dev/null
+++ b/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs
@@ -0,0 +1,68 @@
+namespace EchoHub.Server.Services.Stats;
+
+///
+/// Thread-safe, in-memory accumulator for server-activity counters that have no natural
+/// database timestamp to query after the fact — session connects/disconnects, moderation
+/// actions, and peak concurrency. The periodic stats-report job snapshots and resets these
+/// once per reporting window. Registered as a singleton; every increment is lock-free so it
+/// is safe to call from hot paths (connect/disconnect).
+///
+public sealed class ServerStatsCollector
+{
+ private long _connections;
+ private long _disconnections;
+ private long _kicks;
+ private long _bans;
+ private int _peakOnline;
+
+ /// Record a session coming online, updating the running peak.
+ public void RecordConnection(int onlineNow)
+ {
+ Interlocked.Increment(ref _connections);
+ RecordOnline(onlineNow);
+ }
+
+ /// Record a session going offline, updating the running peak.
+ public void RecordDisconnection(int onlineNow)
+ {
+ Interlocked.Increment(ref _disconnections);
+ RecordOnline(onlineNow);
+ }
+
+ /// Record a kick action.
+ public void RecordKick() => Interlocked.Increment(ref _kicks);
+
+ /// Record a ban action.
+ public void RecordBan() => Interlocked.Increment(ref _bans);
+
+ /// Update the running maximum of concurrent online users (lock-free).
+ public void RecordOnline(int onlineNow)
+ {
+ int current;
+ while (onlineNow > (current = Volatile.Read(ref _peakOnline)))
+ {
+ if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current)
+ break;
+ }
+ }
+
+ ///
+ /// Atomically read all counters and reset them for the next reporting window. The peak is
+ /// reset to so the next window's peak starts from the current
+ /// concurrency rather than zero.
+ ///
+ public StatsCounters SnapshotAndReset(int onlineNow) => new(
+ Connections: Interlocked.Exchange(ref _connections, 0),
+ Disconnections: Interlocked.Exchange(ref _disconnections, 0),
+ Kicks: Interlocked.Exchange(ref _kicks, 0),
+ Bans: Interlocked.Exchange(ref _bans, 0),
+ PeakOnline: Interlocked.Exchange(ref _peakOnline, onlineNow));
+}
+
+/// Immutable snapshot of the counters held by .
+public readonly record struct StatsCounters(
+ long Connections,
+ long Disconnections,
+ long Kicks,
+ long Bans,
+ int PeakOnline);
diff --git a/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs b/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs
new file mode 100644
index 0000000..1e997c9
--- /dev/null
+++ b/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs
@@ -0,0 +1,151 @@
+using System.Text.Json;
+using EchoHub.Core.Models;
+using EchoHub.Server.Config;
+using EchoHub.Server.Data;
+using EchoHub.Server.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace EchoHub.Server.Services.Stats;
+
+///
+/// Background job that periodically snapshots server activity over a window, logs it as
+/// pretty-printed JSON (which also surfaces in the live server-logs room), and persists it to
+/// the database for historical trends. Interval and retention are configurable via the "Stats"
+/// section; the default cadence is every 6 hours.
+///
+public sealed class ServerStatsReportService : BackgroundService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly PresenceTracker _presence;
+ private readonly ServerStatsCollector _collector;
+ private readonly StatsOptions _options;
+ private readonly ILogger _logger;
+
+ // Start of the current reporting window. Advances to PeriodEnd after each report so that
+ // the DB-derived counts and the in-memory collector counters cover the same span.
+ private DateTimeOffset _periodStart;
+
+ public ServerStatsReportService(
+ IServiceScopeFactory scopeFactory,
+ PresenceTracker presence,
+ ServerStatsCollector collector,
+ StatsOptions options,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _presence = presence;
+ _collector = collector;
+ _options = options;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (!_options.Enabled)
+ return;
+
+ // A non-positive interval falls back to the 6h default; anything positive is honoured
+ // but floored at 1s so a mis-set tiny value can't spin the loop.
+ var interval = _options.IntervalHours > 0
+ ? TimeSpan.FromHours(_options.IntervalHours)
+ : TimeSpan.FromHours(6);
+ if (interval < TimeSpan.FromSeconds(1))
+ interval = TimeSpan.FromSeconds(1);
+ _periodStart = DateTimeOffset.UtcNow;
+
+ _logger.LogInformation(
+ "Server stats report job started — reporting every {Hours}h, retention {Days}d",
+ _options.IntervalHours, _options.RetentionDays);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(interval, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+
+ try
+ {
+ await GenerateReportAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Failed to generate periodic server stats report");
+ }
+ }
+ }
+
+ private async Task GenerateReportAsync(CancellationToken ct)
+ {
+ var periodStart = _periodStart;
+ var periodEnd = DateTimeOffset.UtcNow;
+ var onlineNow = _presence.GetOnlineUserCount();
+ var counters = _collector.SnapshotAndReset(onlineNow);
+
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var messagesSent = await db.Messages.CountAsync(m => m.SentAt >= periodStart, ct);
+
+ var activeMembers = await db.Messages
+ .Where(m => m.SentAt >= periodStart)
+ .Select(m => m.SenderUserId)
+ .Distinct()
+ .CountAsync(ct);
+
+ var uploadQuery = db.Attachments
+ .Where(a => db.Messages.Any(m => m.Id == a.MessageId && m.SentAt >= periodStart));
+ var filesUploaded = await uploadQuery.CountAsync(ct);
+ var bytesUploaded = filesUploaded == 0 ? 0L : await uploadQuery.SumAsync(a => a.FileSize, ct);
+
+ var newMembers = await db.Users.CountAsync(u => u.CreatedAt >= periodStart, ct);
+ var totalMembers = await db.Users.CountAsync(ct);
+
+ var report = new ServerStatsReport
+ {
+ Id = Guid.NewGuid(),
+ GeneratedAt = periodEnd,
+ PeriodStart = periodStart,
+ PeriodEnd = periodEnd,
+ WindowHours = Math.Round((periodEnd - periodStart).TotalHours, 2),
+ MessagesSent = messagesSent,
+ FilesUploaded = filesUploaded,
+ BytesUploaded = bytesUploaded,
+ NewMembers = newMembers,
+ ActiveMembers = activeMembers,
+ Connections = (int)counters.Connections,
+ Disconnections = (int)counters.Disconnections,
+ Kicks = (int)counters.Kicks,
+ Bans = (int)counters.Bans,
+ TotalMembers = totalMembers,
+ OnlineNow = onlineNow,
+ PeakOnline = counters.PeakOnline,
+ };
+
+ db.ServerStatsReports.Add(report);
+
+ // Prune reports beyond the retention window (0 = keep forever).
+ if (_options.RetentionDays > 0)
+ {
+ var cutoff = periodEnd.AddDays(-_options.RetentionDays);
+ var stale = await db.ServerStatsReports
+ .Where(r => r.GeneratedAt < cutoff)
+ .ToListAsync(ct);
+ if (stale.Count > 0)
+ db.ServerStatsReports.RemoveRange(stale);
+ }
+
+ await db.SaveChangesAsync(ct);
+ _periodStart = periodEnd;
+
+ // Pretty-printed JSON so the report is readable both in the log files and the logs room.
+ var json = JsonSerializer.Serialize(report, JsonOptions);
+ _logger.LogInformation("Server stats report ({WindowHours}h window):\n{Report}", report.WindowHours, json);
+ }
+}
diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json
index c9a0d9a..1b2e97d 100644
--- a/src/EchoHub.Server/appsettings.example.json
+++ b/src/EchoHub.Server/appsettings.example.json
@@ -56,6 +56,11 @@
"LogDirectory": "logs",
"LogFilePattern": "echohub-server-*.log"
},
+ "Stats": {
+ "Enabled": true,
+ "IntervalHours": 6,
+ "RetentionDays": 90
+ },
"Irc": {
"Enabled": false,
"Port": 6667,