feat(core): add sensor and GPU telemetry collector
This commit is contained in:
1 parent
5c8c6d565f
commit
d43173fe42
6 files changed
+201
No files matched your search
@@ -0,0 +1,8 @@
|
||||
using Vantage.Core.Models;
|
||||
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public interface ISensorProvider
|
||||
{
|
||||
Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Vantage.Core.Models;
|
||||
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public sealed class MockSensorProvider : ISensorProvider
|
||||
{
|
||||
private readonly ConcurrentProfiles profiles = new();
|
||||
private readonly Func<DateTimeOffset> clock;
|
||||
|
||||
public MockSensorProvider(Func<DateTimeOffset>? clock = null)
|
||||
{
|
||||
this.clock = clock ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken)
|
||||
{
|
||||
var seconds = clock().ToUnixTimeSeconds();
|
||||
var wave = Math.Sin(seconds / 120.0) * 6;
|
||||
|
||||
var temperatures = new[]
|
||||
{
|
||||
new TemperatureReading("x86_pkg_temp", Math.Round(48 + wave, 1)),
|
||||
new TemperatureReading("coretemp Core 0", Math.Round(46 + wave * 0.8, 1)),
|
||||
new TemperatureReading("nvme Composite", Math.Round(38 + wave * 0.4, 1)),
|
||||
new TemperatureReading("acpitz", Math.Round(41 + wave * 0.5, 1))
|
||||
};
|
||||
|
||||
var gpus = profiles.HasGpu(host.Id)
|
||||
? new[]
|
||||
{
|
||||
new GpuReading(
|
||||
"NVIDIA GeForce RTX 3060",
|
||||
Math.Round(52 + wave, 1),
|
||||
Math.Round(Math.Clamp(30 + wave * 4, 0, 100), 1),
|
||||
3_200L * 1024 * 1024,
|
||||
12_288L * 1024 * 1024)
|
||||
}
|
||||
: Array.Empty<GpuReading>();
|
||||
|
||||
return Task.FromResult(new SensorSnapshot(temperatures, gpus));
|
||||
}
|
||||
|
||||
private sealed class ConcurrentProfiles
|
||||
{
|
||||
public bool HasGpu(string hostId)
|
||||
{
|
||||
var hash = 0;
|
||||
foreach (var c in hostId)
|
||||
hash = hash * 31 + c;
|
||||
return (hash & 1) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public static class NvidiaSmiParser
|
||||
{
|
||||
public const string QueryArgs =
|
||||
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total " +
|
||||
"--format=csv,noheader,nounits";
|
||||
|
||||
public static IReadOnlyList<GpuReading> Parse(string csvOutput)
|
||||
{
|
||||
var gpus = new List<GpuReading>();
|
||||
|
||||
foreach (var rawLine in csvOutput.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
var fields = line.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (fields.Length < 5)
|
||||
continue;
|
||||
|
||||
gpus.Add(new GpuReading(
|
||||
Name: fields[0],
|
||||
TemperatureCelsius: ParseDouble(fields[1]),
|
||||
UtilizationPercent: ParseDouble(fields[2]),
|
||||
MemoryUsedBytes: (long)(ParseDouble(fields[3]) * 1024 * 1024),
|
||||
MemoryTotalBytes: (long)(ParseDouble(fields[4]) * 1024 * 1024)));
|
||||
}
|
||||
|
||||
return gpus;
|
||||
}
|
||||
|
||||
private static double ParseDouble(string value) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : 0;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public readonly record struct TemperatureReading(string Label, double Celsius);
|
||||
|
||||
public readonly record struct GpuReading(
|
||||
string Name,
|
||||
double TemperatureCelsius,
|
||||
double UtilizationPercent,
|
||||
long MemoryUsedBytes,
|
||||
long MemoryTotalBytes);
|
||||
|
||||
public sealed record SensorSnapshot(
|
||||
IReadOnlyList<TemperatureReading> Temperatures,
|
||||
IReadOnlyList<GpuReading> Gpus)
|
||||
{
|
||||
public static readonly SensorSnapshot Empty =
|
||||
new(Array.Empty<TemperatureReading>(), Array.Empty<GpuReading>());
|
||||
|
||||
public double? HottestCelsius => Temperatures.Count == 0 ? null : Temperatures.Max(t => t.Celsius);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Vantage.Core.Models;
|
||||
using Vantage.Core.Ssh;
|
||||
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public sealed class SshSensorProvider : ISensorProvider
|
||||
{
|
||||
public const string GpuMarker = "@@vantage:gpu";
|
||||
|
||||
private readonly ISshCommandRunner runner;
|
||||
|
||||
public SshSensorProvider(ISshCommandRunner runner)
|
||||
{
|
||||
this.runner = runner;
|
||||
}
|
||||
|
||||
public static string BuildCommand() =>
|
||||
"for z in /sys/class/thermal/thermal_zone*; do " +
|
||||
"[ -r \"$z/temp\" ] && echo \"$(cat \"$z/type\" 2>/dev/null):$(cat \"$z/temp\" 2>/dev/null)\"; done ; " +
|
||||
$"echo '{GpuMarker}' ; " +
|
||||
$"(command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi {NvidiaSmiParser.QueryArgs} 2>/dev/null || true)";
|
||||
|
||||
public static SensorSnapshot ParseBundle(string bundle)
|
||||
{
|
||||
var normalized = bundle.Replace("\r\n", "\n");
|
||||
var markerIndex = normalized.IndexOf(GpuMarker, StringComparison.Ordinal);
|
||||
|
||||
string thermalPart;
|
||||
string gpuPart;
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
thermalPart = normalized;
|
||||
gpuPart = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
thermalPart = normalized[..markerIndex];
|
||||
gpuPart = normalized[(markerIndex + GpuMarker.Length)..];
|
||||
}
|
||||
|
||||
return new SensorSnapshot(
|
||||
ThermalZoneParser.Parse(thermalPart),
|
||||
NvidiaSmiParser.Parse(gpuPart));
|
||||
}
|
||||
|
||||
public async Task<SensorSnapshot> GetSnapshotAsync(HostConfig host, CancellationToken cancellationToken)
|
||||
{
|
||||
var output = await runner.RunAsync(host, BuildCommand(), cancellationToken).ConfigureAwait(false);
|
||||
return ParseBundle(output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Vantage.Core.Collectors.Sensors;
|
||||
|
||||
public static class ThermalZoneParser
|
||||
{
|
||||
public static IReadOnlyList<TemperatureReading> Parse(string zoneOutput)
|
||||
{
|
||||
var readings = new List<TemperatureReading>();
|
||||
|
||||
foreach (var rawLine in zoneOutput.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
var separator = line.LastIndexOf(':');
|
||||
if (separator <= 0 || separator == line.Length - 1)
|
||||
continue;
|
||||
|
||||
var label = line[..separator].Trim();
|
||||
var rawValue = line[(separator + 1)..].Trim();
|
||||
if (!long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var milliCelsius))
|
||||
continue;
|
||||
|
||||
readings.Add(new TemperatureReading(label, Math.Round(milliCelsius / 1000.0, 1)));
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user