Initial commit: JMon cross-platform monitoring system

- gRPC client-server metrics collection
- PeriodicTimer-based 5-second sampling
- Delta encoding with configurable thresholds
- Queue/retry mechanism for resilience
- ProcessId tracking for duplicate detection
- Server-side state reconstruction
- Native AOT compilation support
- Docker/Podman containerization
- PostgreSQL + TimescaleDB persistence layer
This commit is contained in:
2026-01-05 23:23:55 -06:00
commit 127b3ad5bf
16 changed files with 1241 additions and 0 deletions

60
.copilot-instructions.md Normal file
View File

@@ -0,0 +1,60 @@
# JMon Project Guidelines
## Package Management
**IMPORTANT**: Always use `dotnet` CLI commands to manage NuGet packages. Do NOT directly edit `.csproj` files for package management.
Use these commands:
- `dotnet add package <name>` - Add a package
- `dotnet add package <name> --version <version>` - Add specific version
- `dotnet remove package <name>` - Remove a package
- `dotnet nuget add source <url>` - Add NuGet source
- `dotnet nuget remove source <name>` - Remove NuGet source
This ensures:
- Package versions are verified to exist before adding
- Compatibility checks are performed
- Project structure remains clean and consistent
## Architecture
- **Agent**: Worker Service collecting metrics via `/proc` (Linux) or platform APIs
- **Server**: ASP.NET Core gRPC service receiving delta metrics
- **Protocol**: gRPC with Protobuf for binary efficiency
- **Communication**: Unary RPC pattern (request-response), agent initiates
- **Metrics**: CPU%, memory, swap, disk usage, IP address, HWID with delta encoding (~50% bandwidth reduction)
- **Response Commands**: ACK (normal) or SYNC (force full resync)
## Code Style
- C# 12+ features (records, nullable references)
- `#nullable enable` required
- Async/await throughout
- ILogger for all logging
- Use DateTimeOffset for all timestamps (UTC)
## Testing & Deployment
### Agent Debug Mode
To test the agent without sending to a server:
```bash
cd JMonAgent && dotnet run -- --DEBUG=true
```
This prints formatted metrics to console every 5 seconds instead of sending them via gRPC.
### Docker/Podman Setup
Run the full stack with:
```bash
podman compose up --build
```
This starts:
- PostgreSQL 16 with TimescaleDB on localhost:5432
- JMonServer gRPC on localhost:5001
- Metrics automatically persisted to DB with 90-day retention
- Data compressed after 7 days
Connection string: `Host=localhost;Port=5432;Database=jmon;Username=jmon;Password=jmon_dev_password`
- Suppress SignalR warnings by removing unused packages from project files
- Both projects should build with 0 errors

91
.gitignore vendored Normal file
View File

@@ -0,0 +1,91 @@
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio cache/options
.vs/
.vscode/
*.user
*.userosscache
*.sln.docstates
# NuGet
*.nupkg
*.snupkg
.nuget/
# Rider
.idea/
*.sln.iml
.idea_modules/
# macOS
.DS_Store
# Environment variables
.env
.env.local
.env.*.local
# Runtime
*.dll
*.exe
*.so
*.dylib
# Certificates/Keys
*.pfx
*.pem
*.key
*.crt
# Local publish artifacts
/publish/
/PublishProfiles/
# Node (if used for tooling)
node_modules/
# Temporary files
*.tmp
*.temp
*.log
*.swp
*.swo
# Docker
.docker/
docker-compose.override.yml
# Podman
.podman/
# Tests
TestResults/
.coverage/
coverage/
# IDE Settings (keep only workspace settings, not user settings)
.vscode/settings.json
.vscode/launch.json
.vscode/tasks.json
# Protocol Buffer generated files (keep source .proto files)
*.pb.go
*.pb.cs
# OS files
Thumbs.db
.Thumbs.db

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-JMonAgent-7f8df008-660b-45a2-b649-64956a63f63c</UserSecretsId>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.27.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Grpc.Tools" Version="2.65.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="../Protos/metrics.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' != '' AND $(RuntimeIdentifier.StartsWith('win'))">
<PackageReference Include="System.Management" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,286 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace JMonAgent;
public interface IMetricCollector
{
SystemMetrics Collect();
}
public class LinuxMetricCollector : IMetricCollector
{
private long _prevJiffy;
private long _prevIdle;
private string? _cachedIpAddress;
private string? _cachedHwId;
public SystemMetrics Collect()
{
var memInfo = ParseMemInfo();
var swapInfo = ParseSwapInfo();
var diskInfo = ParseDiskInfo();
// Cache IP and HWID on first call
_cachedIpAddress ??= GetIpAddress();
_cachedHwId ??= GetHwId();
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
GetCpuUsage(),
memInfo.UsedBytes,
memInfo.TotalBytes,
swapInfo.UsedBytes,
swapInfo.TotalBytes,
diskInfo.UsedBytes,
diskInfo.TotalBytes,
diskInfo.MountPoint,
DateTime.UtcNow,
_cachedIpAddress ?? "",
_cachedHwId ?? "",
Environment.ProcessId
);
}
private string GetIpAddress()
{
try
{
var psi = new ProcessStartInfo("hostname", "-I")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return "";
var output = process.StandardOutput.ReadToEnd().Trim();
var addresses = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return addresses.FirstOrDefault() ?? "";
}
catch
{
return "";
}
}
private string GetHwId()
{
try
{
// Try to read machine-id from Linux
if (File.Exists("/etc/machine-id"))
{
return File.ReadAllText("/etc/machine-id").Trim();
}
// Fallback: use MAC address from first interface
var interfaces = Directory.GetFiles("/sys/class/net/");
if (interfaces.Length > 0)
{
var firstInterface = Path.GetFileName(interfaces[0]);
var macPath = $"/sys/class/net/{firstInterface}/address";
if (File.Exists(macPath))
{
return File.ReadAllText(macPath).Trim();
}
}
return "";
}
catch
{
return "";
}
}
private (long UsedBytes, long TotalBytes) ParseMemInfo()
{
try
{
var lines = File.ReadAllLines("/proc/meminfo");
var dict = new Dictionary<string, long>();
foreach (var line in lines)
{
var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value))
{
dict[parts[0]] = value * 1024; // Convert from KB to bytes
}
}
long total = dict.TryGetValue("MemTotal", out var t) ? t : 0;
long available = dict.TryGetValue("MemAvailable", out var a) ? a : 0;
long used = total - available;
return (used, total);
}
catch
{
return (0, 0);
}
}
private (long UsedBytes, long TotalBytes) ParseSwapInfo()
{
try
{
var lines = File.ReadAllLines("/proc/meminfo");
var dict = new Dictionary<string, long>();
foreach (var line in lines)
{
var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value))
{
dict[parts[0]] = value * 1024; // Convert from KB to bytes
}
}
long total = dict.TryGetValue("SwapTotal", out var st) ? st : 0;
long free = dict.TryGetValue("SwapFree", out var sf) ? sf : 0;
long used = total - free;
return (used, total);
}
catch
{
return (0, 0);
}
}
private (long UsedBytes, long TotalBytes, string MountPoint) ParseDiskInfo()
{
try
{
// Use 'df' command to get root filesystem info
var psi = new ProcessStartInfo("df", "-B1 /")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return (0, 0, "/");
var output = process.StandardOutput.ReadToEnd();
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Skip header, parse second line
if (lines.Length >= 2)
{
var parts = lines[1].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 4
&& long.TryParse(parts[1], out var total)
&& long.TryParse(parts[2], out var used))
{
return (used, total, parts[parts.Length - 1]);
}
}
return (0, 0, "/");
}
catch
{
return (0, 0, "/");
}
}
private double GetCpuUsage()
{
try
{
var firstLine = File.ReadLines("/proc/stat").First();
var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5) return 0.0;
// /proc/stat format: cpu user nice system idle iowait irq softirq steal guest guest_nice
long user = long.Parse(parts[1]);
long nice = long.Parse(parts[2]);
long system = long.Parse(parts[3]);
long idle = long.Parse(parts[4]);
long totalJiffy = user + nice + system + idle;
long activeJiffy = user + nice + system;
// First call - initialize baseline
if (_prevJiffy == 0)
{
_prevJiffy = totalJiffy;
_prevIdle = idle;
return 0.0;
}
// Calculate deltas
long diffTotal = totalJiffy - _prevJiffy;
long diffIdle = idle - _prevIdle;
_prevJiffy = totalJiffy;
_prevIdle = idle;
if (diffTotal == 0) return 0.0;
double usage = 100.0 * (1.0 - ((double)diffIdle / diffTotal));
return Math.Max(0, Math.Min(100, usage));
}
catch
{
return 0.0;
}
}
}
public class WindowsMetricCollector : IMetricCollector
{
public SystemMetrics Collect()
{
// TODO: Use PerformanceCounters or WMI for Windows
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
0.0,
0,
0,
0,
0,
0,
0,
"C:\\",
DateTime.UtcNow,
"",
"",
Environment.ProcessId
);
}
}
public class MacMetricCollector : IMetricCollector
{
public SystemMetrics Collect()
{
// TODO: Use sysctl for macOS metrics
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
0.0,
0,
0,
0,
0,
0,
0,
"/",
DateTime.UtcNow,
"",
"",
Environment.ProcessId
);
}
}

32
JMonAgent/MetricsQueue.cs Normal file
View File

@@ -0,0 +1,32 @@
using System.Collections.Concurrent;
namespace JMonAgent;
public class MetricsQueue
{
private readonly ConcurrentQueue<JMon.Protos.DeltaMetrics> _queue = new();
private readonly ILogger<MetricsQueue> _logger;
public MetricsQueue(ILogger<MetricsQueue> logger)
{
_logger = logger;
}
public void Enqueue(JMon.Protos.DeltaMetrics metrics)
{
_queue.Enqueue(metrics);
_logger.LogWarning("Metrics queued (total: {count})", _queue.Count);
}
public List<JMon.Protos.DeltaMetrics> DequeueAll()
{
var items = new List<JMon.Protos.DeltaMetrics>();
while (_queue.TryDequeue(out var item))
{
items.Add(item);
}
return items;
}
public int Count => _queue.Count;
}

8
JMonAgent/Program.cs Normal file
View File

@@ -0,0 +1,8 @@
using JMonAgent;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<MetricsQueue>();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"JMonAgent": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace JMonAgent;
public record SystemMetrics(
string MachineName,
string OSDescription,
double CpuUsagePercentage,
long MemoryUsedBytes,
long MemoryTotalBytes,
long SwapUsedBytes,
long SwapTotalBytes,
long DiskUsedBytes,
long DiskTotalBytes,
string DiskMountPoint,
DateTime Timestamp,
string IpAddress,
string HwId,
int ProcessId
);
public record DeltaMetrics(
string MachineName,
string? OSDescription,
double? CpuUsagePercentage,
long? MemoryUsedBytes,
long? MemoryTotalBytes,
long? SwapUsedBytes,
long? SwapTotalBytes,
long? DiskUsedBytes,
long? DiskTotalBytes,
string? DiskMountPoint,
DateTime Timestamp,
string? IpAddress,
string? HwId,
int ProcessId
);
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SystemMetrics))]
[JsonSerializable(typeof(DeltaMetrics))]
internal partial class MetricsJsonContext : JsonSerializerContext
{
}

308
JMonAgent/Worker.cs Normal file
View File

@@ -0,0 +1,308 @@
using System.Runtime.InteropServices;
using Grpc.Net.Client;
using JMon.Protos;
namespace JMonAgent;
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IMetricCollector _collector;
private readonly MetricsQueue _queue;
private readonly bool _debugMode;
private GrpcChannel? _channel;
private MetricsService.MetricsServiceClient? _client;
private SystemMetrics? _previousMetrics;
private readonly SemaphoreSlim _executionLock = new(1, 1);
private const double CpuThreshold = 0.5; // Only send if change > 0.5%
private const long MemoryThreshold = 10 * 1024 * 1024; // 10 MB
private const long SwapThreshold = 10 * 1024 * 1024; // 10 MB
private const long DiskThreshold = 100 * 1024 * 1024; // 100 MB
public Worker(ILogger<Worker> logger, MetricsQueue queue, IConfiguration config)
{
_logger = logger;
_queue = queue;
_debugMode = config.GetValue<bool>("DEBUG", false);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
_collector = new LinuxMetricCollector();
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
_collector = new WindowsMetricCollector();
else
_collector = new MacMetricCollector();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Agent started on {platform}", RuntimeInformation.OSDescription);
if (_debugMode)
{
_logger.LogInformation("DEBUG MODE ENABLED - Metrics will be printed, not sent");
await RunDebugMode(stoppingToken);
}
else
{
await RunNormalMode(stoppingToken);
}
}
private async Task RunDebugMode(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Skip if previous execution is still running (non-blocking lock)
if (!_executionLock.Wait(0))
{
_logger.LogDebug("Skipping tick: previous execution still running");
continue;
}
try
{
var metrics = _collector.Collect();
var delta = CalculateDelta(metrics);
Console.WriteLine("\n=== METRICS DEBUG OUTPUT ===");
Console.WriteLine($"Machine: {delta.MachineName}");
if (delta.OSDescription != null)
Console.WriteLine($"OS: {delta.OSDescription}");
if (delta.IpAddress != null)
Console.WriteLine($"IP: {delta.IpAddress}");
if (delta.HwId != null)
Console.WriteLine($"HWID: {delta.HwId}");
if (delta.CpuUsagePercentage.HasValue)
Console.WriteLine($"CPU: {delta.CpuUsagePercentage:F2}%");
if (delta.MemoryUsedBytes.HasValue)
Console.WriteLine($"Memory: {delta.MemoryUsedBytes.Value / (1024 * 1024)} MB / {delta.MemoryTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB");
if (delta.SwapUsedBytes.HasValue)
Console.WriteLine($"Swap: {delta.SwapUsedBytes.Value / (1024 * 1024)} MB / {delta.SwapTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB");
if (delta.DiskUsedBytes.HasValue)
Console.WriteLine($"Disk: {delta.DiskUsedBytes.Value / (1024 * 1024)} MB / {delta.DiskTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB ({delta.DiskMountPoint})");
Console.WriteLine($"Timestamp: {delta.Timestamp:O}");
Console.WriteLine("===========================\n");
_previousMetrics = metrics;
}
catch (Exception ex)
{
_logger.LogError("Error in debug mode: {msg}", ex.Message);
}
finally
{
_executionLock.Release();
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Debug mode cancelled");
}
}
private async Task RunNormalMode(CancellationToken stoppingToken)
{
// Create gRPC channel (ignore self-signed certs for development)
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;
_channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpHandler = handler
});
_client = new MetricsService.MetricsServiceClient(_channel);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Skip if previous execution is still running (non-blocking lock)
if (!_executionLock.Wait(0))
{
_logger.LogWarning("Skipping tick: previous execution still running (>5s)");
continue;
}
try
{
// Try to flush queued metrics first
if (_queue.Count > 0)
{
var queuedMetrics = _queue.DequeueAll();
_logger.LogInformation("Flushing {count} queued metrics", queuedMetrics.Count);
foreach (var queued in queuedMetrics)
{
try
{
await _client.ReportMetricsAsync(queued, cancellationToken: stoppingToken);
}
catch (Exception ex)
{
_logger.LogError("Failed to flush queued metric: {msg}", ex.Message);
_queue.Enqueue(queued); // Re-queue on failure
// Continue trying other queued metrics instead of breaking
}
}
}
var metrics = _collector.Collect();
_logger.LogDebug("Collected metrics: CPU={cpu}%, Memory={mem}MB/{total}MB",
metrics.CpuUsagePercentage.ToString("F2"),
(metrics.MemoryUsedBytes / (1024 * 1024)),
(metrics.MemoryTotalBytes / (1024 * 1024)));
var delta = CalculateDelta(metrics);
// Convert to gRPC message using object initializer
var grpcDelta = new JMon.Protos.DeltaMetrics
{
MachineName = delta.MachineName,
Hwid = delta.HwId,
TimestampUnixMs = new DateTimeOffset(delta.Timestamp).ToUnixTimeMilliseconds(),
ProcessId = delta.ProcessId
};
if (!string.IsNullOrEmpty(delta.OSDescription))
grpcDelta.OsDescription = delta.OSDescription;
if (delta.CpuUsagePercentage.HasValue)
grpcDelta.CpuUsagePercentage = delta.CpuUsagePercentage.Value;
if (delta.MemoryUsedBytes.HasValue)
grpcDelta.MemoryUsedBytes = delta.MemoryUsedBytes.Value;
if (delta.MemoryTotalBytes.HasValue)
grpcDelta.MemoryTotalBytes = delta.MemoryTotalBytes.Value;
if (delta.SwapUsedBytes.HasValue)
grpcDelta.SwapUsedBytes = delta.SwapUsedBytes.Value;
if (delta.SwapTotalBytes.HasValue)
grpcDelta.SwapTotalBytes = delta.SwapTotalBytes.Value;
if (delta.DiskUsedBytes.HasValue)
grpcDelta.DiskUsedBytes = delta.DiskUsedBytes.Value;
if (delta.DiskTotalBytes.HasValue)
grpcDelta.DiskTotalBytes = delta.DiskTotalBytes.Value;
if (!string.IsNullOrEmpty(delta.DiskMountPoint))
grpcDelta.DiskMountPoint = delta.DiskMountPoint;
if (!string.IsNullOrEmpty(delta.IpAddress))
grpcDelta.IpAddress = delta.IpAddress;
var response = await _client.ReportMetricsAsync(grpcDelta, cancellationToken: stoppingToken);
_logger.LogInformation("Metrics reported: {response}", response.Command);
// Handle server commands
if (response.Command == MetricsResponse.Types.CommandType.Sync)
{
_logger.LogInformation("Server requested full resync: {msg}", response.Message);
_previousMetrics = null; // Reset to send full update next time
}
else if (response.Command == MetricsResponse.Types.CommandType.Sigterm)
{
_logger.LogInformation("Server terminating process: {msg}", response.Message);
Environment.Exit(0);
}
_previousMetrics = metrics;
}
catch (Exception ex)
{
_logger.LogError("Error reporting metrics: {msg}", ex.Message);
// On failure, queue the FULL metrics for retry (not delta)
// This ensures the server can reconstruct properly even if it was restarted
if (ex is not OperationCanceledException)
{
var metrics = _collector.Collect();
var grpcDelta = new JMon.Protos.DeltaMetrics
{
MachineName = metrics.MachineName,
Hwid = metrics.HwId,
TimestampUnixMs = new DateTimeOffset(metrics.Timestamp).ToUnixTimeMilliseconds(),
Retried = true,
ProcessId = metrics.ProcessId,
// Send FULL metrics (all fields) on retry
OsDescription = metrics.OSDescription,
CpuUsagePercentage = metrics.CpuUsagePercentage,
MemoryUsedBytes = metrics.MemoryUsedBytes,
MemoryTotalBytes = metrics.MemoryTotalBytes,
SwapUsedBytes = metrics.SwapUsedBytes,
SwapTotalBytes = metrics.SwapTotalBytes,
DiskUsedBytes = metrics.DiskUsedBytes,
DiskTotalBytes = metrics.DiskTotalBytes,
DiskMountPoint = metrics.DiskMountPoint,
IpAddress = metrics.IpAddress
};
_queue.Enqueue(grpcDelta);
}
}
finally
{
_executionLock.Release();
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Normal mode cancelled");
}
}
private DeltaMetrics CalculateDelta(SystemMetrics current)
{
// First time - send everything
if (_previousMetrics == null)
{
return new DeltaMetrics(
current.MachineName,
current.OSDescription,
current.CpuUsagePercentage,
current.MemoryUsedBytes,
current.MemoryTotalBytes,
current.SwapUsedBytes,
current.SwapTotalBytes,
current.DiskUsedBytes,
current.DiskTotalBytes,
current.DiskMountPoint,
current.Timestamp,
current.IpAddress,
current.HwId
);
}
// Calculate deltas and only include changed values
return new DeltaMetrics(
current.MachineName,
current.OSDescription != _previousMetrics.OSDescription ? current.OSDescription : null,
Math.Abs(current.CpuUsagePercentage - _previousMetrics.CpuUsagePercentage) > CpuThreshold ? current.CpuUsagePercentage : null,
Math.Abs(current.MemoryUsedBytes - _previousMetrics.MemoryUsedBytes) > MemoryThreshold ? current.MemoryUsedBytes : null,
current.MemoryTotalBytes != _previousMetrics.MemoryTotalBytes ? current.MemoryTotalBytes : null,
Math.Abs(current.SwapUsedBytes - _previousMetrics.SwapUsedBytes) > SwapThreshold ? current.SwapUsedBytes : null,
current.SwapTotalBytes != _previousMetrics.SwapTotalBytes ? current.SwapTotalBytes : null,
Math.Abs(current.DiskUsedBytes - _previousMetrics.DiskUsedBytes) > DiskThreshold ? current.DiskUsedBytes : null,
current.DiskTotalBytes != _previousMetrics.DiskTotalBytes ? current.DiskTotalBytes : null,
current.DiskMountPoint != _previousMetrics.DiskMountPoint ? current.DiskMountPoint : null,
current.Timestamp,
current.IpAddress != _previousMetrics.IpAddress ? current.IpAddress : null,
current.HwId // Always send HWID as it's the unique identifier
);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_channel != null)
{
await _channel.ShutdownAsync();
_channel.Dispose();
}
await base.StopAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
}
}

35
JMonServer/Containerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
RUN apt-get update && apt-get install -y build-essential clang zlib1g-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY ["JMonServer/JMonServer.csproj", "JMonServer/"]
COPY ["JMonAgent/JMonAgent.csproj", "JMonAgent/"]
COPY ["Protos/", "Protos/"]
RUN dotnet restore "JMonServer/JMonServer.csproj"
COPY . .
RUN dotnet build "JMonServer/JMonServer.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "JMonServer/JMonServer.csproj" -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=publish /app/publish .
# Generate self-signed PFX certificate for HTTPS (no password)
RUN mkdir -p /root/.aspnet/https && \
openssl req -x509 -newkey rsa:4096 -keyout /tmp/key.pem -out /tmp/cert.pem \
-days 365 -nodes -subj "/CN=*" && \
openssl pkcs12 -export -out /root/.aspnet/https/aspnetapp.pfx -inkey /tmp/key.pem -in /tmp/cert.pem -passout pass: && \
rm /tmp/key.pem /tmp/cert.pem
EXPOSE 5001
ENV ASPNETCORE_URLS=https://0.0.0.0:5001
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/root/.aspnet/https/aspnetapp.pfx
ENV ASPNETCORE_Kestrel__Certificates__Default__Password=
ENV ASPNETCORE_ENVIRONMENT=Development
ENTRYPOINT ["./JMonServer"]

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../JMonAgent/JMonAgent.csproj" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="../Protos/metrics.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.27.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.65.0" />
<PackageReference Include="Grpc.Tools" Version="2.65.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

205
JMonServer/Program.cs Normal file
View File

@@ -0,0 +1,205 @@
using JMonAgent;
using JMon.Protos;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
// Track latest full metrics per machine (keyed by HWID:PID)
var latestMetrics = new Dictionary<string, SystemMetrics>();
// Track pending metrics per machine for replay in timestamp order
var pendingMetrics = new Dictionary<string, List<JMon.Protos.DeltaMetrics>>();
// Track which process is active per HWID (for duplicate detection)
var activeProcesses = new Dictionary<string, (int ProcessId, DateTime LastSeen)>();
builder.Services.AddSingleton(latestMetrics);
builder.Services.AddSingleton(pendingMetrics);
builder.Services.AddSingleton(activeProcesses);
var app = builder.Build();
app.MapGrpcService<MetricsServiceImpl>();
// Use environment variables for URL, fallback to HTTP on 0.0.0.0:5001
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://0.0.0.0:5001";
app.Run(urls);
public class MetricsServiceImpl : MetricsService.MetricsServiceBase
{
private readonly Dictionary<string, SystemMetrics> _latestMetrics;
private readonly Dictionary<string, List<JMon.Protos.DeltaMetrics>> _pendingMetrics;
private readonly Dictionary<string, (int ProcessId, DateTime LastSeen)> _activeProcesses;
public MetricsServiceImpl(Dictionary<string, SystemMetrics> latestMetrics, Dictionary<string, List<JMon.Protos.DeltaMetrics>> pendingMetrics, Dictionary<string, (int ProcessId, DateTime LastSeen)> activeProcesses)
{
_latestMetrics = latestMetrics;
_pendingMetrics = pendingMetrics;
_activeProcesses = activeProcesses;
}
public override async Task<JMon.Protos.MetricsResponse> ReportMetrics(JMon.Protos.DeltaMetrics request, Grpc.Core.ServerCallContext context)
{
var hwid = request.Hwid;
// Check for duplicate processes on same HWID
var command = MetricsResponse.Types.CommandType.Ack;
var message = "Metrics received";
if (_activeProcesses.ContainsKey(hwid))
{
var (activePid, lastSeen) = _activeProcesses[hwid];
if (activePid != request.ProcessId)
{
// Multiple processes detected - SIGTERM the older one, SYNC the newer
if (request.ProcessId > activePid)
{
// New process is newer - terminate the old one
command = MetricsResponse.Types.CommandType.Sigterm;
message = $"Duplicate process detected. Terminating PID {activePid}";
_activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow);
}
else
{
// Current request is older - ask for full sync
command = MetricsResponse.Types.CommandType.Sync;
message = $"Duplicate process detected. Newer process (PID {activePid}) is active";
}
}
else
{
// Same process, just update last seen
_activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow);
}
}
else
{
// First process for this HWID
_activeProcesses[hwid] = (request.ProcessId, DateTime.UtcNow);
}
// If this is a retried metric, queue it for ordered replay
if (request.Retried)
{
if (!_pendingMetrics.ContainsKey(hwid))
_pendingMetrics[hwid] = new List<JMon.Protos.DeltaMetrics>();
_pendingMetrics[hwid].Add(request);
}
// Process all pending metrics for this machine in timestamp order
if (_pendingMetrics.ContainsKey(hwid) && _pendingMetrics[hwid].Count > 0)
{
var pending = _pendingMetrics[hwid];
// Sort by timestamp (oldest first)
var sorted = pending.OrderBy(m => m.TimestampUnixMs).ToList();
foreach (var metric in sorted)
{
ApplyMetric(hwid, metric);
}
// Clear processed pending metrics
_pendingMetrics[hwid].Clear();
}
// Apply the current metric
if (!request.Retried)
{
ApplyMetric(hwid, request);
}
var serverTime = DateTime.UtcNow;
var agentTime = DateTime.UnixEpoch.AddMilliseconds(request.TimestampUnixMs);
var timeDeltaMs = Math.Abs((serverTime - agentTime).TotalMilliseconds);
var key = $"{hwid}:{request.ProcessId}";
if (_latestMetrics.TryGetValue(key, out var latest))
{
DisplayMetrics("[gRPC]", latest, timeDeltaMs, request.Retried);
}
return await Task.FromResult(new JMon.Protos.MetricsResponse
{
Success = true,
Command = command,
Message = message
});
}
private void ApplyMetric(string hwid, JMon.Protos.DeltaMetrics delta)
{
var agentTime = DateTime.UnixEpoch.AddMilliseconds(delta.TimestampUnixMs);
// Use HWID + PID as key
var key = $"{hwid}:{delta.ProcessId}";
// Reconstruct full metrics by merging delta with previous state
SystemMetrics full;
if (_latestMetrics.TryGetValue(key, out var previous))
{
full = new SystemMetrics(
delta.MachineName,
delta.HasOsDescription ? delta.OsDescription : previous.OSDescription,
delta.HasCpuUsagePercentage ? delta.CpuUsagePercentage : previous.CpuUsagePercentage,
delta.HasMemoryUsedBytes ? delta.MemoryUsedBytes : previous.MemoryUsedBytes,
delta.HasMemoryTotalBytes ? delta.MemoryTotalBytes : previous.MemoryTotalBytes,
delta.HasSwapUsedBytes ? delta.SwapUsedBytes : previous.SwapUsedBytes,
delta.HasSwapTotalBytes ? delta.SwapTotalBytes : previous.SwapTotalBytes,
delta.HasDiskUsedBytes ? delta.DiskUsedBytes : previous.DiskUsedBytes,
delta.HasDiskTotalBytes ? delta.DiskTotalBytes : previous.DiskTotalBytes,
delta.HasDiskMountPoint ? delta.DiskMountPoint : previous.DiskMountPoint,
agentTime,
delta.HasIpAddress ? delta.IpAddress : previous.IpAddress,
hwid,
delta.ProcessId
);
}
else
{
// First time - initialize with sent values or defaults
full = new SystemMetrics(
delta.MachineName,
delta.HasOsDescription ? delta.OsDescription : "Unknown",
delta.HasCpuUsagePercentage ? delta.CpuUsagePercentage : 0,
delta.HasMemoryUsedBytes ? delta.MemoryUsedBytes : 0,
delta.HasMemoryTotalBytes ? delta.MemoryTotalBytes : 0,
delta.HasSwapUsedBytes ? delta.SwapUsedBytes : 0,
delta.HasSwapTotalBytes ? delta.SwapTotalBytes : 0,
delta.HasDiskUsedBytes ? delta.DiskUsedBytes : 0,
delta.HasDiskTotalBytes ? delta.DiskTotalBytes : 0,
delta.HasDiskMountPoint ? delta.DiskMountPoint : "/",
agentTime,
delta.HasIpAddress ? delta.IpAddress : "",
hwid,
delta.ProcessId
);
}
_latestMetrics[key] = full;
}
private void DisplayMetrics(string source, SystemMetrics full, double timeDeltaMs, bool retried)
{
var tag = retried ? "[RETRIED]" : "";
Console.WriteLine($"{source} {tag} {full.MachineName} ({full.HwId}) [PID: {full.ProcessId}]:");
Console.WriteLine($" Time Skew: {timeDeltaMs:F1}ms");
if (timeDeltaMs > 60000)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠️ ALERT: Clock skew detected! {timeDeltaMs:F0}ms difference");
Console.ResetColor();
}
if (!string.IsNullOrEmpty(full.IpAddress))
Console.WriteLine($" IP: {full.IpAddress}");
Console.WriteLine($" CPU: {full.CpuUsagePercentage:F2}%");
var memPercent = (full.MemoryTotalBytes > 0) ? (full.MemoryUsedBytes * 100.0 / full.MemoryTotalBytes).ToString("F1") : "0.0";
Console.WriteLine($" Memory: {full.MemoryUsedBytes / (1024*1024)} MB / {full.MemoryTotalBytes / (1024*1024)} MB ({memPercent}%)");
var swapPercent = (full.SwapTotalBytes > 0) ? (full.SwapUsedBytes * 100.0 / full.SwapTotalBytes).ToString("F1") : "0.0";
Console.WriteLine($" Swap: {full.SwapUsedBytes / (1024*1024)} MB / {full.SwapTotalBytes / (1024*1024)} MB ({swapPercent}%)");
var diskPercent = (full.DiskTotalBytes > 0) ? (full.DiskUsedBytes * 100.0 / full.DiskTotalBytes).ToString("F1") : "0.0";
Console.WriteLine($" Disk ({full.DiskMountPoint}): {full.DiskUsedBytes / (1024*1024)} MB / {full.DiskTotalBytes / (1024*1024)} MB ({diskPercent}%)");
Console.WriteLine($" OS: {full.OSDescription}\n");
}
}

39
Protos/metrics.proto Normal file
View File

@@ -0,0 +1,39 @@
syntax = "proto3";
package jmon;
option csharp_namespace = "JMon.Protos";
service MetricsService {
rpc ReportMetrics (DeltaMetrics) returns (MetricsResponse);
}
message DeltaMetrics {
string machine_name = 1;
string hwid = 2;
optional string os_description = 3;
optional double cpu_usage_percentage = 4;
optional int64 memory_used_bytes = 5;
optional int64 memory_total_bytes = 6;
optional int64 swap_used_bytes = 7;
optional int64 swap_total_bytes = 8;
optional int64 disk_used_bytes = 9;
optional int64 disk_total_bytes = 10;
optional string disk_mount_point = 11;
int64 timestamp_unix_ms = 12;
bool retried = 13;
optional string ip_address = 14;
int32 process_id = 15;
}
message MetricsResponse {
enum CommandType {
ACK = 0;
SYNC = 1;
SIGTERM = 2;
}
bool success = 1;
CommandType command = 2;
string message = 3;
}

46
compose.yaml Normal file
View File

@@ -0,0 +1,46 @@
version: '3.8'
services:
postgres:
image: docker.io/library/postgres:16
container_name: jmon-postgres
environment:
POSTGRES_PASSWORD: jmon_dev_password
POSTGRES_DB: jmon
POSTGRES_USER: jmon
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- /home/jgetrost/source/repos/jmon/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:Z
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jmon"]
interval: 10s
timeout: 5s
retries: 5
networks:
- jmon-net
server:
build:
context: .
dockerfile: JMonServer/Containerfile
container_name: jmon-server
depends_on:
postgres:
condition: service_started
environment:
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=jmon;Username=jmon;Password=jmon_dev_password"
ASPNETCORE_URLS: "https://+:5001"
ASPNETCORE_ENVIRONMENT: Development
ports:
- "5001:5001"
networks:
- jmon-net
volumes:
postgres_data:
networks:
jmon-net:
driver: bridge