1
0
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:
chylex 2022-10-07 15:58:27 +02:00
parent b9fa5de76b
commit 0695ee8405
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
19 changed files with 462 additions and 6 deletions

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -21,6 +21,8 @@ else {
@if (lastError != null) {
<p class="text-danger">@lastError</p>
}
<InstanceLog InstanceGuid="InstanceGuid" />
}
@code {

View 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);
}
}

View 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;
}

View 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;
}
}

View File

@ -24,6 +24,7 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
services.AddSingleton<AgentJavaRuntimesManager>();
services.AddSingleton<AgentStatsManager>();
services.AddSingleton<InstanceManager>();
services.AddSingleton<InstanceLogManager>();
services.AddSingleton<MessageToServerListenerFactory>();
}

View File

@ -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>

View 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();
}
}
}

View 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];
}
}
}