feat: add server logs functionality with role-based access and system channel management

- Introduced a new migration to add the IsSystem column to the Channels table.
- Updated the DbContext model snapshot to reflect the new IsSystem property.
- Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion.
- Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles.
- Created ServerLogsSink to queue log events for streaming to the live log room.
- Developed ServerLogsStreamService to stream log events to clients in real-time.
- Added configuration options for server logs in appsettings.
- Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
This commit is contained in:
HueByte
2026-07-17 20:44:47 +02:00
parent df1bd0119c
commit 38eca99fb1
22 changed files with 1569 additions and 17 deletions
@@ -19,6 +19,7 @@ public class ChannelListSource : IListDataSource
private readonly HashSet<string> _protectedChannels = [];
private readonly HashSet<string> _mentionChannels = [];
private readonly HashSet<string> _privateChannels = [];
private readonly HashSet<string> _systemChannels = [];
private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
@@ -31,10 +32,12 @@ public class ChannelListSource : IListDataSource
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
// System channels (e.g. the live log room) render in green to set them apart from user rooms.
private static readonly Attribute SystemAttr = new(Color.BrightGreen, Color.None);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
IReadOnlySet<string>? privateChannels = null)
IReadOnlySet<string>? privateChannels = null, IReadOnlySet<string>? systemChannels = null)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
@@ -50,6 +53,9 @@ public class ChannelListSource : IListDataSource
_privateChannels.Clear();
if (privateChannels is not null)
_privateChannels.UnionWith(privateChannels);
_systemChannels.Clear();
if (systemChannels is not null)
_systemChannels.UnionWith(systemChannels);
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
@@ -71,7 +77,9 @@ public class ChannelListSource : IListDataSource
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " ";
var isSystem = _systemChannels.Contains(name);
// System channels get a leading rule so they read as a pinned, separate group at the top.
var prefix = isSystem ? (isActive ? "▌ " : "▎ ") : (isActive ? "> " : " ");
// Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
var channelText = $"#{name}";
if (_protectedChannels.Contains(name))
@@ -93,12 +101,15 @@ public class ChannelListSource : IListDataSource
}
else
{
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
// System channels are green so they stand apart from user rooms; that green also
// colors the leading rule and (dimmer) prefix.
listView.SetAttribute(Resolve(isSystem ? SystemAttr : isActive ? ActiveAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
// Mentions escalate above plain unread: the whole entry turns orange
var hasMention = _mentionChannels.Contains(name);
var nameAttr = isActive ? ActiveAttr
var nameAttr = isSystem ? SystemAttr
: isActive ? ActiveAttr
: hasMention ? MentionAttr
: hasUnread ? UnreadAttr
: NormalAttr;
@@ -107,7 +118,7 @@ public class ChannelListSource : IListDataSource
if (hasUnread)
{
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
listView.SetAttribute(Resolve(isSystem ? SystemAttr : hasMention ? MentionAttr : BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
+54 -4
View File
@@ -75,6 +75,7 @@ public sealed partial class MainWindow : Runnable
private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly HashSet<string> _channelProtected = [];
private readonly HashSet<string> _systemChannels = [];
private readonly ChannelListSource _channelListSource;
private readonly ChatMessageManager _messageManager;
private string _connectionStatus = "Disconnected";
@@ -406,6 +407,14 @@ public sealed partial class MainWindow : Runnable
private void UpdateInputTitle()
{
// Read-only channels (the live log room) override any reply/staged hint.
if (IsCurrentChannelReadOnly)
{
_inputFrame.Title = "Read-only channel — you cannot type here";
_inputFrame.SetNeedsDraw();
return;
}
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
{
(null, null) => DefaultInputTitle,
@@ -850,6 +859,8 @@ public sealed partial class MainWindow : Runnable
break;
case EnterKey:
if (IsCurrentChannelReadOnly)
break;
var text = _inputField.Text?.Trim() ?? string.Empty;
// Send when there's text, or when only attachments are staged (empty caption).
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
@@ -876,6 +887,9 @@ public sealed partial class MainWindow : Runnable
case CtrlVKey:
case CtrlYKey:
// Read-only channels can't receive text or attachments.
if (IsCurrentChannelReadOnly)
break;
// Discord-style paste priority. Copied files in the OS file manager put a file
// list (not text) on the clipboard — attach them all. Copied image data (browser
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
@@ -1102,6 +1116,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Clear();
_channelPublic.Clear();
_channelProtected.Clear();
_systemChannels.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
@@ -1109,6 +1124,8 @@ public sealed partial class MainWindow : Runnable
_channelPublic[ch.Name] = ch.IsPublic;
if (ch.IsProtected)
_channelProtected.Add(ch.Name);
if (ch.IsSystem)
_systemChannels.Add(ch.Name);
}
RefreshChannelList();
}
@@ -1116,7 +1133,8 @@ public sealed partial class MainWindow : Runnable
/// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary>
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null,
bool? isSystem = null)
{
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
@@ -1127,9 +1145,15 @@ public sealed partial class MainWindow : Runnable
else _channelProtected.Remove(channelName);
}
if (isSystem.HasValue)
{
if (isSystem.Value) _systemChannels.Add(channelName);
else _systemChannels.Remove(channelName);
}
if (_channelNames.Contains(channelName))
{
if (isProtected.HasValue)
if (isProtected.HasValue || isSystem.HasValue)
RefreshChannelList();
return;
}
@@ -1147,6 +1171,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
_channelProtected.Remove(channelName);
_systemChannels.Remove(channelName);
RefreshChannelList();
}
@@ -1337,6 +1362,7 @@ public sealed partial class MainWindow : Runnable
RefreshMessages();
UpdateTopicBar();
UpdateInputReadOnly();
_statusLabel.SetNeedsDraw();
// Update channel list selection
@@ -1345,6 +1371,19 @@ public sealed partial class MainWindow : Runnable
_channelList.SelectedItem = idx;
}
/// <summary>Whether the active channel is read-only (a system channel like the log room).</summary>
private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel);
/// <summary>
/// Disables the input for read-only (system) channels so nothing can be typed there, and
/// reflects the state in the input frame title.
/// </summary>
private void UpdateInputReadOnly()
{
_inputField.ReadOnly = IsCurrentChannelReadOnly;
UpdateInputTitle();
}
/// <summary>
/// Clear all messages and channels (used on disconnect).
/// </summary>
@@ -1355,6 +1394,7 @@ public sealed partial class MainWindow : Runnable
_channelTopics.Clear();
_channelPublic.Clear();
_channelProtected.Clear();
_systemChannels.Clear();
_channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat";
@@ -1459,11 +1499,21 @@ public sealed partial class MainWindow : Runnable
/// </summary>
private void RefreshChannelList()
{
// Pin system channels (e.g. the live log room) to the very top, keeping the server's
// relative order otherwise. OrderBy is stable, so alphabetical order is preserved within
// each group. Reordering in place keeps _channelNames the source of truth for selection
// lookups. System channels are private by nature but shouldn't get the private (~) glyph,
// so exclude them from the private set.
var ordered = _channelNames.OrderBy(n => _systemChannels.Contains(n) ? 0 : 1).ToList();
_channelNames.Clear();
_channelNames.AddRange(ordered);
var privateChannels = _channelNames
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.Where(n => !_systemChannels.Contains(n)
&& _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.ToHashSet();
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
_channelProtected, _messageManager.MentionChannels, privateChannels);
_channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels);
_channelList.Source = _channelListSource;
// Restore selection to current channel