mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
feat: add server logs functionality with role-based access and system channel management
- Introduced a new migration to add the IsSystem column to the Channels table. - Updated the DbContext model snapshot to reflect the new IsSystem property. - Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion. - Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles. - Created ServerLogsSink to queue log events for streaming to the live log room. - Developed ServerLogsStreamService to stream log events to clients in real-time. - Added configuration options for server logs in appsettings. - Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user