mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2025-07-27 18:59:04 +02:00
Add instance logs to web
This commit is contained in:
parent
b9fa5de76b
commit
0695ee8405
Agent
Phantom.Agent.Minecraft
Phantom.Agent.Services/Instances
Common/Phantom.Common.Messages
PhantomPanel.slnServer
Phantom.Server.Services
Phantom.Server.Web
Phantom.Server
Utils
Phantom.Utils.Collections.Tests
Phantom.Utils.Collections
@ -1,8 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Phantom.Utils.Collections;
|
||||
|
||||
namespace Phantom.Agent.Minecraft.Instance;
|
||||
|
||||
public sealed class InstanceSession : IDisposable {
|
||||
private readonly RingBuffer<string> outputBuffer = new (10000);
|
||||
private event EventHandler<string>? OutputEvent;
|
||||
|
||||
public event EventHandler? SessionEnded;
|
||||
@ -22,8 +24,12 @@ public sealed class InstanceSession : IDisposable {
|
||||
await process.StandardInput.WriteLineAsync(command.AsMemory(), cancellationToken);
|
||||
}
|
||||
|
||||
public void AddOutputListener(EventHandler<string> listener) {
|
||||
public void AddOutputListener(EventHandler<string> listener, uint maxLinesToReadFromHistory = uint.MaxValue) {
|
||||
OutputEvent += listener;
|
||||
|
||||
foreach (var line in outputBuffer.EnumerateLast(maxLinesToReadFromHistory)) {
|
||||
listener(this, line);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveOutputListener(EventHandler<string> listener) {
|
||||
@ -32,6 +38,7 @@ public sealed class InstanceSession : IDisposable {
|
||||
|
||||
private void HandleOutputLine(object sender, DataReceivedEventArgs args) {
|
||||
if (args.Data is {} line) {
|
||||
outputBuffer.Add(line);
|
||||
OutputEvent?.Invoke(this, line);
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
|
||||
</ItemGroup>
|
||||
|
@ -0,0 +1,84 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Phantom.Agent.Rpc;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Common.Messages.ToServer;
|
||||
using Phantom.Utils.Collections;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Agent.Services.Instances;
|
||||
|
||||
sealed class InstanceLogSenderThread {
|
||||
private readonly Guid instanceGuid;
|
||||
private readonly ILogger logger;
|
||||
private readonly CancellationTokenSource cancellationTokenSource;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
|
||||
private readonly SemaphoreSlim semaphore = new (1, 1);
|
||||
private readonly RingBuffer<string> buffer = new (1000);
|
||||
|
||||
public InstanceLogSenderThread(Guid instanceGuid, string name) {
|
||||
this.instanceGuid = instanceGuid;
|
||||
this.logger = PhantomLogger.Create<InstanceLogSenderThread>(name);
|
||||
this.cancellationTokenSource = new CancellationTokenSource();
|
||||
this.cancellationToken = cancellationTokenSource.Token;
|
||||
|
||||
var thread = new Thread(Run) {
|
||||
IsBackground = true,
|
||||
Name = "Instance Log Sender (" + name + ")"
|
||||
};
|
||||
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "LocalVariableHidesMember")]
|
||||
private async void Run() {
|
||||
logger.Verbose("Thread started.");
|
||||
|
||||
try {
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
ImmutableArray<string> lines;
|
||||
|
||||
try {
|
||||
lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
|
||||
buffer.Clear();
|
||||
} finally {
|
||||
semaphore.Release();
|
||||
}
|
||||
|
||||
if (lines.Length > 0) {
|
||||
await ServerMessaging.SendMessage(new InstanceOutputMessage(instanceGuid, lines));
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Ignore.
|
||||
} catch (Exception e) {
|
||||
logger.Error(e, "Caught exception in thread.");
|
||||
} finally {
|
||||
cancellationTokenSource.Dispose();
|
||||
logger.Verbose("Thread stopped.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(string line) {
|
||||
try {
|
||||
semaphore.Wait(cancellationToken);
|
||||
} catch (Exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
buffer.Add(line);
|
||||
} finally {
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel() {
|
||||
cancellationTokenSource.Cancel();
|
||||
}
|
||||
}
|
@ -6,12 +6,14 @@ namespace Phantom.Agent.Services.Instances.States;
|
||||
sealed class InstanceRunningState : IInstanceState {
|
||||
private readonly InstanceContext context;
|
||||
private readonly InstanceSession session;
|
||||
private readonly InstanceLogSenderThread logSenderThread;
|
||||
private readonly SessionObjects sessionObjects;
|
||||
|
||||
public InstanceRunningState(InstanceContext context, InstanceSession session) {
|
||||
this.context = context;
|
||||
this.session = session;
|
||||
this.sessionObjects = new SessionObjects(context, session);
|
||||
this.logSenderThread = new InstanceLogSenderThread(context.Configuration.InstanceGuid, context.ShortName);
|
||||
this.sessionObjects = new SessionObjects(context, session, logSenderThread);
|
||||
|
||||
this.session.AddOutputListener(SessionOutput);
|
||||
this.session.SessionEnded += SessionEnded;
|
||||
@ -31,6 +33,7 @@ sealed class InstanceRunningState : IInstanceState {
|
||||
|
||||
private void SessionOutput(object? sender, string e) {
|
||||
context.Logger.Verbose("[Server] {Line}", e);
|
||||
logSenderThread.Enqueue(e);
|
||||
}
|
||||
|
||||
private void SessionEnded(object? sender, EventArgs e) {
|
||||
@ -53,11 +56,13 @@ sealed class InstanceRunningState : IInstanceState {
|
||||
public sealed class SessionObjects {
|
||||
private readonly InstanceContext context;
|
||||
private readonly InstanceSession session;
|
||||
private readonly InstanceLogSenderThread logSenderThread;
|
||||
private bool isDisposed;
|
||||
|
||||
public SessionObjects(InstanceContext context, InstanceSession session) {
|
||||
public SessionObjects(InstanceContext context, InstanceSession session, InstanceLogSenderThread logSenderThread) {
|
||||
this.context = context;
|
||||
this.session = session;
|
||||
this.logSenderThread = logSenderThread;
|
||||
}
|
||||
|
||||
public bool Dispose() {
|
||||
@ -69,6 +74,7 @@ sealed class InstanceRunningState : IInstanceState {
|
||||
isDisposed = true;
|
||||
}
|
||||
|
||||
logSenderThread.Cancel();
|
||||
session.Dispose();
|
||||
context.PortManager.Release(context.Configuration);
|
||||
return true;
|
||||
|
@ -9,5 +9,6 @@ public interface IMessageToServerListener {
|
||||
Task HandleAgentIsAlive(AgentIsAliveMessage message);
|
||||
Task HandleAdvertiseJavaRuntimes(AdvertiseJavaRuntimesMessage message);
|
||||
Task HandleReportInstanceStatus(ReportInstanceStatusMessage message);
|
||||
Task HandleInstanceOutput(InstanceOutputMessage message);
|
||||
Task HandleSimpleReply(SimpleReplyMessage message);
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ public static class MessageRegistries {
|
||||
ToServer.Add<AgentIsAliveMessage>(2);
|
||||
ToServer.Add<AdvertiseJavaRuntimesMessage>(3);
|
||||
ToServer.Add<ReportInstanceStatusMessage>(4);
|
||||
ToServer.Add<InstanceOutputMessage>(5);
|
||||
ToServer.Add<SimpleReplyMessage>(127);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,14 @@
|
||||
using System.Collections.Immutable;
|
||||
using MessagePack;
|
||||
|
||||
namespace Phantom.Common.Messages.ToServer;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed record InstanceOutputMessage(
|
||||
[property: Key(0)] Guid InstanceGuid,
|
||||
[property: Key(1)] ImmutableArray<string> Lines
|
||||
) : IMessageToServer {
|
||||
public Task Accept(IMessageToServerListener listener) {
|
||||
return listener.HandleInstanceOutput(this);
|
||||
}
|
||||
}
|
@ -46,6 +46,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Web.Componen
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Collections", "Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj", "{444AC6C1-E0E1-45C3-965E-BFA818D70913}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Collections.Tests", "Utils\Phantom.Utils.Collections.Tests\Phantom.Utils.Collections.Tests.csproj", "{C418CCDB-2D7E-4B66-8C86-029928AA80A8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Cryptography", "Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj", "{31661196-164A-4F92-86A1-31F13F4E4C83}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Utils.Events", "Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj", "{2E81523B-5DBE-4992-A77B-1679758D0688}"
|
||||
@ -128,6 +130,10 @@ Global
|
||||
{444AC6C1-E0E1-45C3-965E-BFA818D70913}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{444AC6C1-E0E1-45C3-965E-BFA818D70913}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{444AC6C1-E0E1-45C3-965E-BFA818D70913}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C418CCDB-2D7E-4B66-8C86-029928AA80A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C418CCDB-2D7E-4B66-8C86-029928AA80A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C418CCDB-2D7E-4B66-8C86-029928AA80A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C418CCDB-2D7E-4B66-8C86-029928AA80A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{31661196-164A-4F92-86A1-31F13F4E4C83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{31661196-164A-4F92-86A1-31F13F4E4C83}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{31661196-164A-4F92-86A1-31F13F4E4C83}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@ -176,6 +182,7 @@ Global
|
||||
{388A2C9C-0EE2-45A4-B9EB-76FA12B1AF2E} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
|
||||
{BB112660-7A20-45E6-9195-65363B74027F} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
|
||||
{6D03AEE5-B40F-4FC5-84C0-C9643E4EF8BD} = {AA217EB8-E480-456B-BDF3-39419EF2AD85}
|
||||
{C418CCDB-2D7E-4B66-8C86-029928AA80A8} = {7A3C7B26-26A0-49B9-8505-5F8267C10F10}
|
||||
{742599E6-2FC2-4B39-85B8-976C98013030} = {7A3C7B26-26A0-49B9-8505-5F8267C10F10}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
@ -0,0 +1,44 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Phantom.Common.Logging;
|
||||
using Phantom.Utils.Collections;
|
||||
using Phantom.Utils.Events;
|
||||
using Serilog;
|
||||
|
||||
namespace Phantom.Server.Services.Instances;
|
||||
|
||||
public sealed class InstanceLogManager {
|
||||
private const int RetainedLines = 1000;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, ObservableInstanceLogs> logsByInstanceGuid = new ();
|
||||
|
||||
private ObservableInstanceLogs GetInstanceLogs(Guid instanceGuid) {
|
||||
return logsByInstanceGuid.GetOrAdd(instanceGuid, static _ => new ObservableInstanceLogs(PhantomLogger.Create<InstanceManager, ObservableInstanceLogs>()));
|
||||
}
|
||||
|
||||
internal void AddLines(Guid instanceGuid, ImmutableArray<string> lines) {
|
||||
GetInstanceLogs(instanceGuid).Add(lines);
|
||||
}
|
||||
|
||||
public EventSubscribers<RingBuffer<string>> GetSubs(Guid instanceGuid) {
|
||||
return GetInstanceLogs(instanceGuid).Subs;
|
||||
}
|
||||
|
||||
private sealed class ObservableInstanceLogs : ObservableState<RingBuffer<string>> {
|
||||
private readonly RingBuffer<string> log = new (RetainedLines);
|
||||
|
||||
public ObservableInstanceLogs(ILogger logger) : base(logger) {}
|
||||
|
||||
public void Add(ImmutableArray<string> lines) {
|
||||
foreach (var line in lines) {
|
||||
log.Add(line);
|
||||
}
|
||||
|
||||
Update();
|
||||
}
|
||||
|
||||
protected override RingBuffer<string> GetData() {
|
||||
return log;
|
||||
}
|
||||
}
|
||||
}
|
@ -15,18 +15,20 @@ public sealed class MessageToServerListener : IMessageToServerListener {
|
||||
private readonly AgentManager agentManager;
|
||||
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
|
||||
private readonly InstanceManager instanceManager;
|
||||
private readonly InstanceLogManager instanceLogManager;
|
||||
|
||||
private Guid? agentGuid;
|
||||
private readonly TaskCompletionSource<Guid> agentGuidWaiter = new ();
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
internal MessageToServerListener(RpcClientConnection connection, ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager) {
|
||||
internal MessageToServerListener(RpcClientConnection connection, ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager) {
|
||||
this.connection = connection;
|
||||
this.cancellationToken = configuration.CancellationToken;
|
||||
this.agentManager = agentManager;
|
||||
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
|
||||
this.instanceManager = instanceManager;
|
||||
this.instanceLogManager = instanceLogManager;
|
||||
}
|
||||
|
||||
public async Task HandleRegisterAgent(RegisterAgentMessage message) {
|
||||
@ -67,6 +69,11 @@ public sealed class MessageToServerListener : IMessageToServerListener {
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleInstanceOutput(InstanceOutputMessage message) {
|
||||
instanceLogManager.AddLines(message.InstanceGuid, message.Lines);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleSimpleReply(SimpleReplyMessage message) {
|
||||
MessageReplyTracker.Instance.ReceiveReply(message);
|
||||
return Task.CompletedTask;
|
||||
|
@ -9,15 +9,17 @@ public sealed class MessageToServerListenerFactory {
|
||||
private readonly AgentManager agentManager;
|
||||
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
|
||||
private readonly InstanceManager instanceManager;
|
||||
private readonly InstanceLogManager instanceLogManager;
|
||||
|
||||
public MessageToServerListenerFactory(ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager) {
|
||||
public MessageToServerListenerFactory(ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager) {
|
||||
this.configuration = configuration;
|
||||
this.agentManager = agentManager;
|
||||
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
|
||||
this.instanceManager = instanceManager;
|
||||
this.instanceLogManager = instanceLogManager;
|
||||
}
|
||||
|
||||
public MessageToServerListener CreateListener(RpcClientConnection connection) {
|
||||
return new MessageToServerListener(connection, configuration, agentManager, agentJavaRuntimesManager, instanceManager);
|
||||
return new MessageToServerListener(connection, configuration, agentManager, agentJavaRuntimesManager, instanceManager, instanceLogManager);
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,8 @@ else {
|
||||
@if (lastError != null) {
|
||||
<p class="text-danger">@lastError</p>
|
||||
}
|
||||
|
||||
<InstanceLog InstanceGuid="InstanceGuid" />
|
||||
}
|
||||
|
||||
@code {
|
||||
|
53
Server/Phantom.Server.Web/Shared/InstanceLog.razor
Normal file
53
Server/Phantom.Server.Web/Shared/InstanceLog.razor
Normal file
@ -0,0 +1,53 @@
|
||||
@using Phantom.Server.Services.Instances
|
||||
@using Phantom.Utils.Collections
|
||||
@using Phantom.Utils.Events
|
||||
@implements IDisposable
|
||||
@inject IJSRuntime Js;
|
||||
@inject InstanceLogManager InstanceLogManager
|
||||
|
||||
<div id="log" class="font-monospace mb-3">
|
||||
@foreach (var line in instanceLogs.EnumerateLast(uint.MaxValue)) {
|
||||
<p>@line</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public Guid InstanceGuid { get; set; }
|
||||
|
||||
private IJSObjectReference? PageJs { get; set; }
|
||||
|
||||
private EventSubscribers<RingBuffer<string>> instanceLogsSubs = null!;
|
||||
private RingBuffer<string> instanceLogs = null!;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
instanceLogsSubs = InstanceLogManager.GetSubs(InstanceGuid);
|
||||
instanceLogsSubs.Subscribe(this, buffer => {
|
||||
instanceLogs = buffer;
|
||||
InvokeAsync(RefreshLog);
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (firstRender) {
|
||||
PageJs = await Js.InvokeAsync<IJSObjectReference>("import", "./Shared/InstanceLog.razor.js");
|
||||
StateHasChanged();
|
||||
|
||||
await PageJs.InvokeVoidAsync("initLog");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshLog() {
|
||||
StateHasChanged();
|
||||
|
||||
if (PageJs != null) {
|
||||
await PageJs.InvokeVoidAsync("scrollLog");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
instanceLogsSubs.Unsubscribe(this);
|
||||
}
|
||||
}
|
17
Server/Phantom.Server.Web/Shared/InstanceLog.razor.css
Normal file
17
Server/Phantom.Server.Web/Shared/InstanceLog.razor.css
Normal file
@ -0,0 +1,17 @@
|
||||
#log {
|
||||
min-height: 15rem;
|
||||
height: 60vh;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid #666666;
|
||||
border-radius: 0.25rem;
|
||||
color: #282828;
|
||||
background-color: #fafafa;
|
||||
overflow-y: auto;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#log > p {
|
||||
margin: 0;
|
||||
}
|
34
Server/Phantom.Server.Web/Shared/InstanceLog.razor.js
Normal file
34
Server/Phantom.Server.Web/Shared/InstanceLog.razor.js
Normal file
@ -0,0 +1,34 @@
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
let log;
|
||||
let shouldAutoScroll = false;
|
||||
let isAutoScrolling = false;
|
||||
|
||||
export function initLog() {
|
||||
log = document.getElementById("log");
|
||||
|
||||
if (log) {
|
||||
shouldAutoScroll = true;
|
||||
log.scrollTop = log.scrollHeight;
|
||||
log.addEventListener("scroll", function() {
|
||||
if (isAutoScrolling) {
|
||||
isAutoScrolling = false;
|
||||
}
|
||||
else {
|
||||
setTimeout(function() {
|
||||
shouldAutoScroll = log.scrollHeight - log.scrollTop - log.clientHeight < 5;
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.error("Missing log element.");
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollLog() {
|
||||
if (shouldAutoScroll) {
|
||||
isAutoScrolling = true;
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
|
||||
services.AddSingleton<AgentJavaRuntimesManager>();
|
||||
services.AddSingleton<AgentStatsManager>();
|
||||
services.AddSingleton<InstanceManager>();
|
||||
services.AddSingleton<InstanceLogManager>();
|
||||
services.AddSingleton<MessageToServerListenerFactory>();
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
98
Utils/Phantom.Utils.Collections.Tests/RingBufferTests.cs
Normal file
98
Utils/Phantom.Utils.Collections.Tests/RingBufferTests.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Phantom.Utils.Collections.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public sealed class RingBufferTests {
|
||||
private static RingBuffer<string> PrepareRingBuffer(int capacity, params string[] items) {
|
||||
var buffer = new RingBuffer<string>(capacity);
|
||||
|
||||
foreach (var item in items) {
|
||||
buffer.Add(item);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public sealed class Count {
|
||||
[Test]
|
||||
public void OneItem() {
|
||||
var buffer = PrepareRingBuffer(10, "a");
|
||||
Assert.That(buffer, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleItemsWithinCapacity() {
|
||||
var buffer = PrepareRingBuffer(10, "a", "b", "c");
|
||||
Assert.That(buffer, Has.Count.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleItemsOverflowingCapacity() {
|
||||
var buffer = PrepareRingBuffer(3, "a", "b", "c", "d", "e", "f");
|
||||
Assert.That(buffer, Has.Count.EqualTo(3));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Last {
|
||||
[Test]
|
||||
public void OneItem() {
|
||||
var buffer = PrepareRingBuffer(10, "a");
|
||||
Assert.That(buffer.Last, Is.EqualTo("a"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleItemsWithinCapacity() {
|
||||
var buffer = PrepareRingBuffer(10, "a", "b", "c");
|
||||
Assert.That(buffer.Last, Is.EqualTo("c"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleItemsOverflowingCapacity() {
|
||||
var buffer = PrepareRingBuffer(3, "a", "b", "c", "d", "e", "f");
|
||||
Assert.That(buffer.Last, Is.EqualTo("f"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EnumerateLast {
|
||||
[Test]
|
||||
public void AddOneItemAndEnumerateOne() {
|
||||
var buffer = PrepareRingBuffer(10, "a");
|
||||
Assert.That(buffer.EnumerateLast(1), Is.EquivalentTo(new [] { "a" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddOneItemAndEnumerateMaxValue() {
|
||||
var buffer = PrepareRingBuffer(10, "a");
|
||||
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddMultipleItemsWithinCapacityAndEnumerateFewer() {
|
||||
var buffer = PrepareRingBuffer(10, "a", "b", "c");
|
||||
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "b", "c" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddMultipleItemsWithinCapacityAndEnumerateMaxValue() {
|
||||
var buffer = PrepareRingBuffer(10, "a", "b", "c");
|
||||
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a", "b", "c" }));
|
||||
}
|
||||
|
||||
[TestCase(3)]
|
||||
[TestCase(4)]
|
||||
[TestCase(5)]
|
||||
public void AddMultipleItemsOverflowingCapacityAndEnumerateFewer(int capacity) {
|
||||
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
|
||||
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "e", "f" }));
|
||||
}
|
||||
|
||||
[TestCase(3, ExpectedResult = new [] { "d", "e", "f" })]
|
||||
[TestCase(4, ExpectedResult = new [] { "c", "d", "e", "f" })]
|
||||
[TestCase(5, ExpectedResult = new [] { "b", "c", "d", "e", "f" })]
|
||||
public string[] AddMultipleItemsOverflowingCapacityAndEnumerateMaxValue(int capacity) {
|
||||
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
|
||||
return buffer.EnumerateLast(uint.MaxValue).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
52
Utils/Phantom.Utils.Collections/RingBuffer.cs
Normal file
52
Utils/Phantom.Utils.Collections/RingBuffer.cs
Normal file
@ -0,0 +1,52 @@
|
||||
namespace Phantom.Utils.Collections;
|
||||
|
||||
public sealed class RingBuffer<T> {
|
||||
private readonly T[] buffer;
|
||||
|
||||
private int writeIndex;
|
||||
|
||||
public RingBuffer(int capacity) {
|
||||
this.buffer = new T[capacity];
|
||||
}
|
||||
|
||||
public int Capacity => buffer.Length;
|
||||
public int Count { get; private set; }
|
||||
|
||||
public T Last => Count == 0 ? throw new InvalidOperationException("Ring buffer is empty.") : buffer[IndexOfItemFromEnd(1)];
|
||||
|
||||
private int IndexOfItemFromEnd(int offset) {
|
||||
return (writeIndex - offset + Capacity) % Capacity;
|
||||
}
|
||||
|
||||
public void Add(T item) {
|
||||
buffer[writeIndex++] = item;
|
||||
Count = Math.Max(writeIndex, Count);
|
||||
writeIndex %= Capacity;
|
||||
}
|
||||
|
||||
public void Clear() {
|
||||
Count = 0;
|
||||
writeIndex = 0;
|
||||
}
|
||||
|
||||
public IEnumerable<T> EnumerateLast(uint maximumItems) {
|
||||
int totalItemsToReturn = (int) Math.Min(maximumItems, Count);
|
||||
|
||||
// Yield items until we hit the end of the buffer.
|
||||
|
||||
int startIndex = IndexOfItemFromEnd(totalItemsToReturn);
|
||||
int endOrMaxIndex = Math.Min(startIndex + totalItemsToReturn, Count);
|
||||
|
||||
for (int i = startIndex; i < endOrMaxIndex; i++) {
|
||||
yield return buffer[i];
|
||||
}
|
||||
|
||||
// Wrap around and yield remaining items.
|
||||
|
||||
int remainingItemsToReturn = totalItemsToReturn - (endOrMaxIndex - startIndex);
|
||||
|
||||
for (int i = 0; i < remainingItemsToReturn; i++) {
|
||||
yield return buffer[i];
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user