feat(core): add SSH metrics provider and bundle parser

This commit is contained in:
dualfroz committed 2026-08-24 20:58:47 +02:00
1 parent 9e09780cc0
commit 7c44f54376
5 files changed
+165

No files matched your search

@@ -0,0 +1,8 @@
using Vantage.Core.Models;
namespace Vantage.Core.Metrics;
public interface IMetricsProvider
{
Task<HostMetrics> GetMetricsAsync(HostConfig host, CancellationToken cancellationToken);
}
@@ -0,0 +1,70 @@
using Vantage.Core.Metrics.Parsing;
using Vantage.Core.Models;
namespace Vantage.Core.Metrics;
public static class MetricsBundleParser
{
public static HostMetrics Parse(string bundle)
{
var sections = SplitSections(bundle);
var load = LoadAvgParser.Parse(Require(sections, MetricsCommand.LoadAvg));
var uptime = UptimeParser.Parse(Require(sections, MetricsCommand.Uptime));
var memory = MemInfoParser.Parse(Require(sections, MetricsCommand.MemInfo));
var disk = DfParser.Parse(Require(sections, MetricsCommand.Df));
var statBefore = ProcStatParser.Parse(Require(sections, MetricsCommand.Stat1));
var statAfter = ProcStatParser.Parse(Require(sections, MetricsCommand.Stat2));
var cpu = CpuUsage.Compute(statBefore, statAfter);
return new HostMetrics
{
CpuUsagePercent = cpu,
MemoryUsedBytes = memory.UsedBytes,
MemoryTotalBytes = memory.TotalBytes,
DiskUsedBytes = disk.UsedBytes,
DiskTotalBytes = disk.TotalBytes,
Load1 = load.Load1,
Load5 = load.Load5,
Load15 = load.Load15,
Uptime = uptime,
CollectedAt = DateTimeOffset.UtcNow
};
}
private static Dictionary<string, string> SplitSections(string bundle)
{
var sections = new Dictionary<string, string>(StringComparer.Ordinal);
string? current = null;
var buffer = new List<string>();
void Flush()
{
if (current is not null)
sections[current] = string.Join('\n', buffer);
buffer.Clear();
}
foreach (var rawLine in bundle.Replace("\r\n", "\n").Split('\n'))
{
if (rawLine.StartsWith(MetricsCommand.MarkerPrefix, StringComparison.Ordinal))
{
Flush();
current = rawLine[MetricsCommand.MarkerPrefix.Length..].Trim();
}
else
{
buffer.Add(rawLine);
}
}
Flush();
return sections;
}
private static string Require(IReadOnlyDictionary<string, string> sections, string name) =>
sections.TryGetValue(name, out var value)
? value
: throw new FormatException($"Metrics bundle is missing section '{name}'");
}
@@ -0,0 +1,30 @@
namespace Vantage.Core.Metrics;
public static class MetricsCommand
{
public const string MarkerPrefix = "@@vantage:";
public const string LoadAvg = "loadavg";
public const string Uptime = "uptime";
public const string MemInfo = "meminfo";
public const string Df = "df";
public const string Stat1 = "stat1";
public const string Stat2 = "stat2";
public static string Build(string diskMount, int cpuSampleMs = 400)
{
var mount = Ssh.ShellArgs.Quote(diskMount);
var sleepSeconds = (cpuSampleMs / 1000.0).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
return string.Join(" ; ", new[]
{
Emit(LoadAvg) + "cat /proc/loadavg",
Emit(Uptime) + "cat /proc/uptime",
Emit(MemInfo) + "cat /proc/meminfo",
Emit(Df) + $"df -B1 -P {mount}",
Emit(Stat1) + "cat /proc/stat",
$"sleep {sleepSeconds}",
Emit(Stat2) + "cat /proc/stat"
});
}
private static string Emit(string section) => $"echo '{MarkerPrefix}{section}' ; ";
}
@@ -0,0 +1,36 @@
namespace Vantage.Core.Metrics;
public sealed class MetricsHistory
{
private readonly double[] buffer;
private int count;
private int head;
public MetricsHistory(int capacity)
{
if (capacity < 1)
throw new ArgumentOutOfRangeException(nameof(capacity), "History capacity must be positive");
buffer = new double[capacity];
}
public int Capacity => buffer.Length;
public int Count => count;
public double Latest => count == 0 ? 0 : buffer[(head - 1 + buffer.Length) % buffer.Length];
public void Add(double value)
{
buffer[head] = value;
head = (head + 1) % buffer.Length;
if (count < buffer.Length)
count++;
}
public IReadOnlyList<double> Snapshot()
{
var ordered = new double[count];
var start = (head - count + buffer.Length) % buffer.Length;
for (var i = 0; i < count; i++)
ordered[i] = buffer[(start + i) % buffer.Length];
return ordered;
}
}
@@ -0,0 +1,21 @@
using Vantage.Core.Models;
using Vantage.Core.Ssh;
namespace Vantage.Core.Metrics;
public sealed class SshMetricsProvider : IMetricsProvider
{
private readonly ISshCommandRunner runner;
public SshMetricsProvider(ISshCommandRunner runner)
{
this.runner = runner;
}
public async Task<HostMetrics> GetMetricsAsync(HostConfig host, CancellationToken cancellationToken)
{
var command = MetricsCommand.Build(host.DiskMount);
var bundle = await runner.RunAsync(host, command, cancellationToken).ConfigureAwait(false);
return MetricsBundleParser.Parse(bundle);
}
}