From 7c44f543766dd7747e02d3b6de0d32097b241d2d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 24 Aug 2026 20:58:47 +0200 Subject: [PATCH] feat(core): add SSH metrics provider and bundle parser --- src/Vantage.Core/Metrics/IMetricsProvider.cs | 8 +++ .../Metrics/MetricsBundleParser.cs | 70 +++++++++++++++++++ src/Vantage.Core/Metrics/MetricsCommand.cs | 30 ++++++++ src/Vantage.Core/Metrics/MetricsHistory.cs | 36 ++++++++++ .../Metrics/SshMetricsProvider.cs | 21 ++++++ 5 files changed, 165 insertions(+) create mode 100644 src/Vantage.Core/Metrics/IMetricsProvider.cs create mode 100644 src/Vantage.Core/Metrics/MetricsBundleParser.cs create mode 100644 src/Vantage.Core/Metrics/MetricsCommand.cs create mode 100644 src/Vantage.Core/Metrics/MetricsHistory.cs create mode 100644 src/Vantage.Core/Metrics/SshMetricsProvider.cs diff --git a/src/Vantage.Core/Metrics/IMetricsProvider.cs b/src/Vantage.Core/Metrics/IMetricsProvider.cs new file mode 100644 index 0000000..2884d14 --- /dev/null +++ b/src/Vantage.Core/Metrics/IMetricsProvider.cs @@ -0,0 +1,8 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Metrics; + +public interface IMetricsProvider +{ + Task GetMetricsAsync(HostConfig host, CancellationToken cancellationToken); +} diff --git a/src/Vantage.Core/Metrics/MetricsBundleParser.cs b/src/Vantage.Core/Metrics/MetricsBundleParser.cs new file mode 100644 index 0000000..8b64c88 --- /dev/null +++ b/src/Vantage.Core/Metrics/MetricsBundleParser.cs @@ -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 SplitSections(string bundle) + { + var sections = new Dictionary(StringComparer.Ordinal); + string? current = null; + var buffer = new List(); + + 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 sections, string name) => + sections.TryGetValue(name, out var value) + ? value + : throw new FormatException($"Metrics bundle is missing section '{name}'"); +} diff --git a/src/Vantage.Core/Metrics/MetricsCommand.cs b/src/Vantage.Core/Metrics/MetricsCommand.cs new file mode 100644 index 0000000..2e99579 --- /dev/null +++ b/src/Vantage.Core/Metrics/MetricsCommand.cs @@ -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}' ; "; +} diff --git a/src/Vantage.Core/Metrics/MetricsHistory.cs b/src/Vantage.Core/Metrics/MetricsHistory.cs new file mode 100644 index 0000000..bd869e2 --- /dev/null +++ b/src/Vantage.Core/Metrics/MetricsHistory.cs @@ -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 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; + } +} diff --git a/src/Vantage.Core/Metrics/SshMetricsProvider.cs b/src/Vantage.Core/Metrics/SshMetricsProvider.cs new file mode 100644 index 0000000..40e1429 --- /dev/null +++ b/src/Vantage.Core/Metrics/SshMetricsProvider.cs @@ -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 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); + } +}