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:
60
.copilot-instructions.md
Normal file
60
.copilot-instructions.md
Normal 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
91
.gitignore
vendored
Normal 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
|
||||||
30
JMonAgent/JMonAgent.csproj
Normal file
30
JMonAgent/JMonAgent.csproj
Normal 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>
|
||||||
286
JMonAgent/MetricCollector.cs
Normal file
286
JMonAgent/MetricCollector.cs
Normal 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
32
JMonAgent/MetricsQueue.cs
Normal 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
8
JMonAgent/Program.cs
Normal 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();
|
||||||
12
JMonAgent/Properties/launchSettings.json
Normal file
12
JMonAgent/Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"JMonAgent": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
JMonAgent/SystemMetrics.cs
Normal file
44
JMonAgent/SystemMetrics.cs
Normal 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
308
JMonAgent/Worker.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
JMonAgent/appsettings.Development.json
Normal file
8
JMonAgent/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
JMonAgent/appsettings.json
Normal file
10
JMonAgent/appsettings.json
Normal 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
35
JMonServer/Containerfile
Normal 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"]
|
||||||
27
JMonServer/JMonServer.csproj
Normal file
27
JMonServer/JMonServer.csproj
Normal 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
205
JMonServer/Program.cs
Normal 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
39
Protos/metrics.proto
Normal 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
46
compose.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user