using Explorer.Contracts; namespace Explorer.Application; public interface IHostActivitySink { void Record(string category, string message); IReadOnlyList TakeRecent(int max = 120); } public sealed class NullHostActivitySink : IHostActivitySink { public static NullHostActivitySink Instance { get; } = new(); public void Record(string category, string message) { } public IReadOnlyList TakeRecent(int max = 120) => []; } /// Bounded ring of host activity lines for the live monitor. Cheap to write; snapshot is a copy. public sealed class HostActivityLog : IHostActivitySink { private readonly object _gate = new(); private readonly HostActivityEvent[] _ring; private int _next; private int _count; private string? _lastKey; private DateTimeOffset _lastUtc; public HostActivityLog(int capacity = 48) { _ring = new HostActivityEvent[Math.Clamp(capacity, 32, 200)]; } public void Record(string category, string message) { if (string.IsNullOrWhiteSpace(message)) { return; } var cat = string.IsNullOrWhiteSpace(category) ? "Host" : category.Trim(); var msg = message.Trim(); var key = cat + "\u001f" + msg; var now = DateTimeOffset.UtcNow; lock (_gate) { if (key == _lastKey && (now - _lastUtc) < TimeSpan.FromMilliseconds(750)) { return; } _lastKey = key; _lastUtc = now; _ring[_next] = new HostActivityEvent { Utc = now, Category = cat, Message = msg }; _next = (_next + 1) % _ring.Length; if (_count < _ring.Length) { _count++; } } } public IReadOnlyList TakeRecent(int max = 120) { max = Math.Clamp(max, 1, _ring.Length); lock (_gate) { var take = Math.Min(max, _count); if (take == 0) { return []; } var result = new HostActivityEvent[take]; var start = (_next - take + _ring.Length) % _ring.Length; for (var i = 0; i < take; i++) { result[i] = _ring[(start + i) % _ring.Length]; } return result; } } }