feat(ui): add decision-log reader and overlay components
This commit is contained in:
parent
c1259ecba4
commit
2dc711a55c
9 changed files with 469 additions and 0 deletions
93
ui/JevOverlay/Core/LogTail.cs
Normal file
93
ui/JevOverlay/Core/LogTail.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Text;
|
||||
|
||||
namespace JevOverlay.Core;
|
||||
|
||||
public sealed record TailBatch(IReadOnlyList<string> Lines, bool Reset = false,
|
||||
string? Error = null, bool CatchingUp = false, int SkippedLines = 0);
|
||||
|
||||
/// <summary>Single-worker, bounded JSONL reader. It never writes to the source.</summary>
|
||||
public sealed class LogTail(string path)
|
||||
{
|
||||
public const int MaxReadBytes = 256 * 1024;
|
||||
public const int MaxLineBytes = 128 * 1024;
|
||||
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||
private readonly MemoryStream pending = new();
|
||||
private long offset;
|
||||
private byte[] prefix = [];
|
||||
private byte[] checkpoint = [];
|
||||
private bool initialized;
|
||||
private bool discardLine;
|
||||
|
||||
public TailBatch Poll()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
long length = stream.Length;
|
||||
bool reset = !initialized || length < offset ||
|
||||
!Matches(stream, 0, prefix) ||
|
||||
!Matches(stream, offset - checkpoint.Length, checkpoint);
|
||||
if (reset)
|
||||
{
|
||||
// Start near the end of an existing log, not at the start of a long run.
|
||||
offset = Math.Max(0, length - MaxReadBytes);
|
||||
pending.SetLength(0);
|
||||
discardLine = offset > 0;
|
||||
prefix = ReadAt(stream, 0, (int)Math.Min(64, length));
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset));
|
||||
offset += bytes.Length;
|
||||
checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset));
|
||||
List<string> lines = [];
|
||||
int skipped = 0;
|
||||
foreach (byte value in bytes)
|
||||
{
|
||||
if (value == (byte)'\n')
|
||||
{
|
||||
if (!discardLine && pending.Length > 0)
|
||||
{
|
||||
try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); }
|
||||
catch (DecoderFallbackException) { skipped++; }
|
||||
}
|
||||
discardLine = false;
|
||||
pending.SetLength(0);
|
||||
}
|
||||
else if (!discardLine)
|
||||
{
|
||||
if (pending.Length == MaxLineBytes)
|
||||
{
|
||||
pending.SetLength(0);
|
||||
discardLine = true;
|
||||
skipped++;
|
||||
}
|
||||
else pending.WriteByte(value);
|
||||
}
|
||||
}
|
||||
return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new([], Error: "Log unavailable. Check log_path and file permissions.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Matches(FileStream stream, long position, byte[] expected) =>
|
||||
expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected);
|
||||
|
||||
private static byte[] ReadAt(FileStream stream, long position, int count)
|
||||
{
|
||||
stream.Position = position;
|
||||
byte[] buffer = new byte[count];
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int next = stream.Read(buffer, read, count - read);
|
||||
if (next == 0) break;
|
||||
read += next;
|
||||
}
|
||||
return read == count ? buffer : buffer[..read];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue