mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
Merge branch 'master' into dev
This commit is contained in:
@@ -75,3 +75,19 @@ Irc__Enabled=false
|
|||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────────────
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
# Serilog__MinimumLevel__Default=Information
|
# 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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
|
||||||
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
||||||
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
||||||
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.16
|
||||||
|
href: v0.2.16.md
|
||||||
- name: v0.2.15
|
- name: v0.2.15
|
||||||
href: v0.2.15.md
|
href: v0.2.15.md
|
||||||
- name: v0.2.14
|
- name: v0.2.14
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# v0.2.16
|
||||||
|
|
||||||
|
A logging and observability pass built on the new server-logs room. Busy servers stop drowning
|
||||||
|
in connect/disconnect noise, the events operators actually care about — uploads, moderation
|
||||||
|
actions — now get logged, and a new periodic report summarizes server activity as pretty-printed
|
||||||
|
JSON that streams into the logs room and is saved to the database for history.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Periodic server-stats report** — every 6 hours (configurable), the server logs an aggregate
|
||||||
|
activity snapshot as pretty-printed JSON and saves it to the database. Each report covers the
|
||||||
|
window since the previous one: messages sent, files uploaded (and total bytes), new members,
|
||||||
|
active members (distinct senders), session connections/disconnections, kicks, bans, total
|
||||||
|
registered members, users online now, and peak concurrent online. Reports also stream into the
|
||||||
|
live server-logs room, and old ones are pruned past a configurable retention. New `Stats` config
|
||||||
|
section (`Stats:Enabled`, `Stats:IntervalHours` = 6, `Stats:RetentionDays` = 90).
|
||||||
|
- **Upload logging** — every file, image, and audio upload is now logged with its resource URL
|
||||||
|
(`/api/files/{id}`), kind, size, uploader, and channel. In end-to-end encrypted rooms the
|
||||||
|
filename is client-side ciphertext, so it's logged as `[encrypted]` — the server still never
|
||||||
|
sees room content.
|
||||||
|
- **Moderation & user-action logging** — role changes, kicks, bans, unbans, mutes, unmutes,
|
||||||
|
moderator message removals, and channel nukes are now logged with the actor, target, and reason.
|
||||||
|
Bans and channel nukes log at Warning level so they stand out.
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Quieter connection logs** — user connect, disconnect, join-channel, and leave-channel events
|
||||||
|
(over both SignalR and IRC) dropped from Information to Debug. On a busy server these fired
|
||||||
|
constantly and buried everything else; the aggregate counts now live in the periodic stats
|
||||||
|
report instead. They still show in Development, where the default level is Debug.
|
||||||
|
- **Config examples stay in sync** — `.env.example` gained the `ServerLogs` and `Stats` sections
|
||||||
|
(the former had been missing), alongside `appsettings.json` / `appsettings.example.json`.
|
||||||
|
|
||||||
|
## Notes for server operators
|
||||||
|
|
||||||
|
- New config section: `Stats` — `Enabled` (default true), `IntervalHours` (default 6),
|
||||||
|
`RetentionDays` (default 90, `0` keeps reports forever). See `appsettings.example.json` and
|
||||||
|
`.env.example`. A non-positive interval falls back to 6h; positive values are honored down to a
|
||||||
|
1-second floor.
|
||||||
|
- One new database migration (`AddServerStatsReports`) applies automatically on startup.
|
||||||
|
- Connect/disconnect/join/leave now log at Debug — if you relied on those Information lines, lower
|
||||||
|
`Serilog:MinimumLevel:Default` (or `ServerLogs:MinLevel` for the room) to `Debug`, or read the
|
||||||
|
periodic stats report for aggregates.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.15</Version>
|
<Version>0.2.16</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class ServerStatsReport
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this report was generated (equals <see cref="PeriodEnd"/>).</summary>
|
||||||
|
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Start of the reporting window.</summary>
|
||||||
|
public DateTimeOffset PeriodStart { get; set; }
|
||||||
|
|
||||||
|
/// <summary>End of the reporting window.</summary>
|
||||||
|
public DateTimeOffset PeriodEnd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Length of the reporting window in hours.</summary>
|
||||||
|
public double WindowHours { get; set; }
|
||||||
|
|
||||||
|
// ── Activity during the window ──────────────────────────────────────────
|
||||||
|
/// <summary>Messages sent during the window.</summary>
|
||||||
|
public int MessagesSent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Attachments (files/images/audio) uploaded during the window.</summary>
|
||||||
|
public int FilesUploaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total bytes across all attachments uploaded during the window.</summary>
|
||||||
|
public long BytesUploaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Accounts registered during the window ("new members joined").</summary>
|
||||||
|
public int NewMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Distinct users who sent at least one message during the window.</summary>
|
||||||
|
public int ActiveMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Session connects during the window (per-connection, across SignalR + IRC).</summary>
|
||||||
|
public int Connections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Session disconnects during the window ("members left" sessions).</summary>
|
||||||
|
public int Disconnections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Users kicked during the window.</summary>
|
||||||
|
public int Kicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Users banned during the window.</summary>
|
||||||
|
public int Bans { get; set; }
|
||||||
|
|
||||||
|
// ── Point-in-time totals at window end ──────────────────────────────────
|
||||||
|
/// <summary>Total registered (unique) members at window end.</summary>
|
||||||
|
public int TotalMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Distinct users online at window end.</summary>
|
||||||
|
public int OnlineNow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Peak distinct users online observed during the window.</summary>
|
||||||
|
public int PeakOnline { get; set; }
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
var connection = new IrcClientConnection(tcpClient, stream);
|
var connection = new IrcClientConnection(tcpClient, stream);
|
||||||
_connections[connection.ConnectionId] = connection;
|
_connections[connection.ConnectionId] = connection;
|
||||||
|
|
||||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
_logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId);
|
||||||
|
|
||||||
IChatService? chatService = null;
|
IChatService? chatService = null;
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
_connections.TryRemove(connection.ConnectionId, out _);
|
_connections.TryRemove(connection.ConnectionId, out _);
|
||||||
await connection.DisposeAsync();
|
await connection.DisposeAsync();
|
||||||
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
|
_logger.LogDebug("IRC client {Id} ({Nick}) disconnected",
|
||||||
connection.ConnectionId, connection.Nickname ?? "unregistered");
|
connection.ConnectionId, connection.Nickname ?? "unregistered");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic server-stats report settings, bound from the "Stats" config section (env override:
|
||||||
|
/// <c>Stats__Enabled</c> etc.). When enabled, a background job periodically snapshots server
|
||||||
|
/// activity, logs it as pretty-printed JSON, and persists it to the database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StatsOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for the periodic stats report job.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>How often a report is generated, in hours. Default: every 6 hours.</summary>
|
||||||
|
public double IntervalHours { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long persisted reports are kept before being pruned, in days. Set to 0 to keep
|
||||||
|
/// reports indefinitely. Default: 90 days.
|
||||||
|
/// </summary>
|
||||||
|
public int RetentionDays { get; set; } = 90;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase
|
|||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly UploadLimits _uploadLimits;
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
private readonly ILogger<ChannelsController> _logger;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
@@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
UploadLimits uploadLimits)
|
UploadLimits uploadLimits,
|
||||||
|
ILogger<ChannelsController> logger)
|
||||||
{
|
{
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_uploadLimits = uploadLimits;
|
_uploadLimits = uploadLimits;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -289,6 +292,12 @@ public class ChannelsController : ControllerBase
|
|||||||
});
|
});
|
||||||
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
||||||
_encryption.EncryptNullable(previewPlain)));
|
_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;
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
@@ -446,6 +455,10 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
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(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(string.Empty),
|
_encryption.Encrypt(string.Empty),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using EchoHub.Core.DTOs;
|
|||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -22,19 +23,25 @@ public class ModerationController : ControllerBase
|
|||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly ServerStatsCollector _statsCollector;
|
||||||
|
private readonly ILogger<ModerationController> _logger;
|
||||||
|
|
||||||
public ModerationController(
|
public ModerationController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters)
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
ServerStatsCollector statsCollector,
|
||||||
|
ILogger<ModerationController> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
|
_statsCollector = statsCollector;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("role")]
|
[HttpPost("role")]
|
||||||
@@ -56,9 +63,14 @@ public class ModerationController : ControllerBase
|
|||||||
if (request.Role >= caller!.Role)
|
if (request.Role >= caller!.Role)
|
||||||
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||||
|
|
||||||
|
var previousRole = target.Role;
|
||||||
target.Role = request.Role;
|
target.Role = request.Role;
|
||||||
await _db.SaveChangesAsync();
|
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}." });
|
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.";
|
var reason = request?.Reason ?? "You have been kicked from the server.";
|
||||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
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." });
|
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.";
|
var reason = request?.Reason ?? "You have been banned from this server.";
|
||||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
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." });
|
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("unban/{username}")]
|
[HttpPost("unban/{username}")]
|
||||||
public async Task<IActionResult> UnbanUser(string username)
|
public async Task<IActionResult> UnbanUser(string username)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
@@ -127,6 +149,8 @@ public class ModerationController : ControllerBase
|
|||||||
target.IsBanned = false;
|
target.IsBanned = false;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username);
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +173,12 @@ public class ModerationController : ControllerBase
|
|||||||
: null;
|
: null;
|
||||||
await _db.SaveChangesAsync();
|
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" : "";
|
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||||
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||||
}
|
}
|
||||||
@@ -156,7 +186,7 @@ public class ModerationController : ControllerBase
|
|||||||
[HttpPost("unmute/{username}")]
|
[HttpPost("unmute/{username}")]
|
||||||
public async Task<IActionResult> UnmuteUser(string username)
|
public async Task<IActionResult> UnmuteUser(string username)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
@@ -167,6 +197,8 @@ public class ModerationController : ControllerBase
|
|||||||
target.MutedUntil = null;
|
target.MutedUntil = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username);
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +251,19 @@ public class ModerationController : ControllerBase
|
|||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
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." });
|
return Ok(new { Message = "Message deleted." });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("channels/{channel}/nuke")]
|
[HttpDelete("channels/{channel}/nuke")]
|
||||||
public async Task<IActionResult> NukeChannel(string channel)
|
public async Task<IActionResult> NukeChannel(string channel)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
@@ -251,6 +289,10 @@ public class ModerationController : ControllerBase
|
|||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
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." });
|
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class EchoHubDbContext : DbContext
|
|||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
||||||
|
public DbSet<ServerStatsReport> ServerStatsReports => Set<ServerStatsReport>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -107,6 +108,12 @@ public class EchoHubDbContext : DbContext
|
|||||||
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
|
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ServerStatsReport>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.HasIndex(r => r.GeneratedAt);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(entity =>
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(r => r.Id);
|
entity.HasKey(r => r.Id);
|
||||||
|
|||||||
+437
@@ -0,0 +1,437 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AsciiPreview")
|
||||||
|
.HasMaxLength(64000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MessageId");
|
||||||
|
|
||||||
|
b.ToTable("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionSalt")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WrappedRoomKey")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedByUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxUses")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UseCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("InviteCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("AttachmentFileSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplyToMessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Bans")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("BytesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Connections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Disconnections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("FilesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("GeneratedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kicks")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MessagesSent")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("NewMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("OnlineNow")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PeakOnline")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodEnd")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodStart")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TotalMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("WindowHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GeneratedAt");
|
||||||
|
|
||||||
|
b.ToTable("ServerStatsReports");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddServerStatsReports : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ServerStatsReports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
GeneratedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
PeriodStart = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
PeriodEnd = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
WindowHours = table.Column<double>(type: "REAL", nullable: false),
|
||||||
|
MessagesSent = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
FilesUploaded = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
BytesUploaded = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
NewMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ActiveMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Connections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Disconnections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Kicks = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Bans = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TotalMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
OnlineNow = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PeakOnline = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ServerStatsReports", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ServerStatsReports_GeneratedAt",
|
||||||
|
table: "ServerStatsReports",
|
||||||
|
column: "GeneratedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ServerStatsReports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Bans")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("BytesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Connections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Disconnections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("FilesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("GeneratedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kicks")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MessagesSent")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("NewMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("OnlineNow")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PeakOnline")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodEnd")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodStart")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TotalMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("WindowHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GeneratedAt");
|
||||||
|
|
||||||
|
b.ToTable("ServerStatsReports");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using EchoHub.Server.Hubs;
|
|||||||
using EchoHub.Server.Irc;
|
using EchoHub.Server.Irc;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using EchoHub.Server.Services.ServerLogs;
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using EchoHub.Server.Setup;
|
using EchoHub.Server.Setup;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -142,6 +143,13 @@ while (true)
|
|||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
builder.Services.AddHostedService<MuteExpirationService>();
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|
||||||
|
// ── Periodic stats report (admin-configurable via the "Stats" section) ─
|
||||||
|
var statsOptions = builder.Configuration.GetSection("Stats").Get<StatsOptions>() ?? new StatsOptions();
|
||||||
|
builder.Services.AddSingleton(statsOptions);
|
||||||
|
builder.Services.AddSingleton<ServerStatsCollector>();
|
||||||
|
if (statsOptions.Enabled)
|
||||||
|
builder.Services.AddHostedService<ServerStatsReportService>();
|
||||||
|
|
||||||
// Live server-log streaming (only when the sink is active)
|
// Live server-log streaming (only when the sink is active)
|
||||||
if (serverLogsSink is not null)
|
if (serverLogsSink is not null)
|
||||||
builder.Services.AddHostedService<ServerLogsStreamService>();
|
builder.Services.AddHostedService<ServerLogsStreamService>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using EchoHub.Core.Models;
|
|||||||
using EchoHub.Core.Security;
|
using EchoHub.Core.Security;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services.ServerLogs;
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -23,6 +24,7 @@ public class ChatService : IChatService
|
|||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly SpamGuard _spamGuard;
|
private readonly SpamGuard _spamGuard;
|
||||||
private readonly ServerLogsService _serverLogs;
|
private readonly ServerLogsService _serverLogs;
|
||||||
|
private readonly ServerStatsCollector _statsCollector;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -35,6 +37,7 @@ public class ChatService : IChatService
|
|||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
SpamGuard spamGuard,
|
SpamGuard spamGuard,
|
||||||
ServerLogsService serverLogs,
|
ServerLogsService serverLogs,
|
||||||
|
ServerStatsCollector statsCollector,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -46,12 +49,14 @@ public class ChatService : IChatService
|
|||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_spamGuard = spamGuard;
|
_spamGuard = spamGuard;
|
||||||
_serverLogs = serverLogs;
|
_serverLogs = serverLogs;
|
||||||
|
_statsCollector = statsCollector;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
_presenceTracker.UserConnected(connectionId, userId, username);
|
_presenceTracker.UserConnected(connectionId, userId, username);
|
||||||
|
_statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount());
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
@@ -64,7 +69,9 @@ public class ChatService : IChatService
|
|||||||
await db.SaveChangesAsync();
|
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<string?> UserDisconnectedAsync(string connectionId)
|
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
||||||
@@ -75,6 +82,7 @@ public class ChatService : IChatService
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
var username = _presenceTracker.UserDisconnected(connectionId);
|
var username = _presenceTracker.UserDisconnected(connectionId);
|
||||||
|
_statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount());
|
||||||
|
|
||||||
if (username is not null && !_presenceTracker.IsOnline(username))
|
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;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +173,7 @@ public class ChatService : IChatService
|
|||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
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);
|
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
||||||
@@ -177,7 +185,7 @@ public class ChatService : IChatService
|
|||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
_presenceTracker.LeaveChannel(username, channelName);
|
_presenceTracker.LeaveChannel(username, channelName);
|
||||||
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
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<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
namespace EchoHub.Server.Services.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerStatsCollector
|
||||||
|
{
|
||||||
|
private long _connections;
|
||||||
|
private long _disconnections;
|
||||||
|
private long _kicks;
|
||||||
|
private long _bans;
|
||||||
|
private int _peakOnline;
|
||||||
|
|
||||||
|
/// <summary>Record a session coming online, updating the running peak.</summary>
|
||||||
|
public void RecordConnection(int onlineNow)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _connections);
|
||||||
|
RecordOnline(onlineNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record a session going offline, updating the running peak.</summary>
|
||||||
|
public void RecordDisconnection(int onlineNow)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _disconnections);
|
||||||
|
RecordOnline(onlineNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record a kick action.</summary>
|
||||||
|
public void RecordKick() => Interlocked.Increment(ref _kicks);
|
||||||
|
|
||||||
|
/// <summary>Record a ban action.</summary>
|
||||||
|
public void RecordBan() => Interlocked.Increment(ref _bans);
|
||||||
|
|
||||||
|
/// <summary>Update the running maximum of concurrent online users (lock-free).</summary>
|
||||||
|
public void RecordOnline(int onlineNow)
|
||||||
|
{
|
||||||
|
int current;
|
||||||
|
while (onlineNow > (current = Volatile.Read(ref _peakOnline)))
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomically read all counters and reset them for the next reporting window. The peak is
|
||||||
|
/// reset to <paramref name="onlineNow"/> so the next window's peak starts from the current
|
||||||
|
/// concurrency rather than zero.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Immutable snapshot of the counters held by <see cref="ServerStatsCollector"/>.</summary>
|
||||||
|
public readonly record struct StatsCounters(
|
||||||
|
long Connections,
|
||||||
|
long Disconnections,
|
||||||
|
long Kicks,
|
||||||
|
long Bans,
|
||||||
|
int PeakOnline);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ServerStatsReportService> _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<ServerStatsReportService> 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<EchoHubDbContext>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@
|
|||||||
"LogDirectory": "logs",
|
"LogDirectory": "logs",
|
||||||
"LogFilePattern": "echohub-server-*.log"
|
"LogFilePattern": "echohub-server-*.log"
|
||||||
},
|
},
|
||||||
|
"Stats": {
|
||||||
|
"Enabled": true,
|
||||||
|
"IntervalHours": 6,
|
||||||
|
"RetentionDays": 90
|
||||||
|
},
|
||||||
"Irc": {
|
"Irc": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Port": 6667,
|
"Port": 6667,
|
||||||
|
|||||||
Reference in New Issue
Block a user