================================================================================
  RMM (Remote Monitoring & Management) - Complete Project Reference
  Built with ASP.NET Core 8, SignalR, SQLite, Entity Framework Core
================================================================================

SOLUTION STRUCTURE
==================
RmmSolution.sln
  src/RmmAgent/          - Console agent (runs on managed machines)
  src/RmmServer/         - ASP.NET Core web server + dashboard
    Controllers/         - REST API controllers
    Data/                - EF Core DbContext
    Hubs/                - SignalR hub
    Models/              - Entity models
    wwwroot/             - Static dashboard (index.html)

FEATURES
========
- Real-time metrics (CPU, RAM, disk, network, uptime) via SignalR
- Remote command execution (terminal in browser)
- File browser with file download
- Remote desktop (Windows agents) - JPEG screen streaming + mouse/keyboard
- Alerts (auto-generated on high CPU/RAM/disk)
- Device management (add, remove, remove offline)
- Agent runs silently (WinExe, no console window, auto-starts on login)
- Self-contained agent binaries for Linux/Windows/macOS (~66MB each)

NuGet PACKAGES
==============
RmmServer:
  - Microsoft.EntityFrameworkCore.Sqlite
  - Microsoft.EntityFrameworkCore.Design
  - Microsoft.AspNetCore.SignalR  (built-in to ASP.NET Core 8)

RmmAgent:
  - Microsoft.AspNetCore.SignalR.Client  8.0.11
  - System.Drawing.Common               8.0.0  (screen capture on Windows)

================================================================================
FILE: RmmSolution.sln
================================================================================

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RmmServer", "src\RmmServer\RmmServer.csproj", "{SERVER-GUID}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RmmAgent", "src\RmmAgent\RmmAgent.csproj", "{AGENT-GUID}"
EndProject
Global
  GlobalSection(SolutionConfigurationPlatforms) = preSolution
    Debug|Any CPU = Debug|Any CPU
    Release|Any CPU = Release|Any CPU
  EndGlobalSection
EndGlobal

================================================================================
FILE: src/RmmAgent/RmmAgent.csproj
================================================================================

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>        <!-- No console window on Windows -->
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
    <PackageReference Include="System.Drawing.Common" Version="8.0.0" />
  </ItemGroup>
</Project>

Build self-contained single-file binaries:
  dotnet publish -c Release -r win-x64   --self-contained true -p:PublishSingleFile=true -o publish/win
  dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux
  dotnet publish -c Release -r osx-x64   --self-contained true -p:PublishSingleFile=true -o publish/osx

================================================================================
FILE: src/RmmAgent/Program.cs
================================================================================

using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using Microsoft.AspNetCore.SignalR.Client;

string serverUrl;
var configFile = Path.Combine(AppContext.BaseDirectory, "rmm-agent.conf");

// Priority: command-line arg > saved config > first-run interactive prompt
if (args.Length > 0)
{
    serverUrl = args[0].Trim();
    File.WriteAllText(configFile, serverUrl);
    RegisterStartup();
}
else if (File.Exists(configFile))
{
    serverUrl = File.ReadAllText(configFile).Trim();
    RegisterStartup();
}
else
{
    // First run: temporarily create a console for URL input, then hide it
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        NativeWin32.AllocConsole();

    Console.WriteLine("===========================================");
    Console.WriteLine("  RMM Agent - First Time Setup");
    Console.WriteLine("===========================================");
    Console.WriteLine();
    Console.Write("Enter your RMM dashboard URL (e.g. https://your-dashboard.replit.app): ");
    serverUrl = Console.ReadLine()?.Trim() ?? "";
    while (string.IsNullOrEmpty(serverUrl))
    {
        Console.Write("URL cannot be empty: ");
        serverUrl = Console.ReadLine()?.Trim() ?? "";
    }
    File.WriteAllText(configFile, serverUrl);
    RegisterStartup();
    Console.WriteLine("Agent configured. Running silently in background...");
    await Task.Delay(1500);

    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        NativeWin32.FreeConsole();
}

var agentId = Environment.GetEnvironmentVariable("RMM_AGENT_ID") ?? Guid.NewGuid().ToString("N")[..12];

var connection = new HubConnectionBuilder()
    .WithUrl($"{serverUrl}/hubs/agent")
    .WithAutomaticReconnect()
    .Build();

CancellationTokenSource? rdCts = null;

// ---- Handler: Execute shell command sent from dashboard ----
connection.On<int, string>("ExecuteCommand", async (commandId, commandText) =>
{
    try
    {
        var (shell, args2) = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
            ? ("cmd.exe", $"/c {commandText}")
            : ("/bin/sh", $"-c \"{commandText.Replace("\"", "\\\"")}\"");

        var psi = new ProcessStartInfo(shell, args2)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true
        };

        using var proc = Process.Start(psi)!;
        var output = await proc.StandardOutput.ReadToEndAsync();
        var error = await proc.StandardError.ReadToEndAsync();
        await proc.WaitForExitAsync();

        await connection.InvokeAsync("SendCommandResult", new
        {
            CommandId = commandId,
            Output = output,
            Error = error,
            ExitCode = proc.ExitCode
        });
    }
    catch (Exception ex)
    {
        await connection.InvokeAsync("SendCommandResult", new
        {
            CommandId = commandId,
            Output = "",
            Error = ex.Message,
            ExitCode = -1
        });
    }
});

// ---- Handler: Browse directory ----
connection.On<string, string>("BrowseDirectory", async (requestId, path) =>
{
    try
    {
        var target = string.IsNullOrEmpty(path) ? GetDefaultRoot() : path;
        var entries = new List<object>();

        if (Directory.Exists(target))
        {
            foreach (var dir in Directory.GetDirectories(target).OrderBy(x => x).Take(250))
            {
                try
                {
                    var info = new DirectoryInfo(dir);
                    entries.Add(new { name = info.Name, fullPath = dir, type = "directory", size = (long)0, modified = info.LastWriteTimeUtc });
                }
                catch { }
            }
            foreach (var file in Directory.GetFiles(target).OrderBy(x => x).Take(500 - entries.Count))
            {
                try
                {
                    var info = new FileInfo(file);
                    entries.Add(new { name = info.Name, fullPath = file, type = "file", size = info.Length, modified = info.LastWriteTimeUtc });
                }
                catch { }
            }
            await connection.InvokeAsync("SendFileListing", requestId, target, entries);
        }
        else
        {
            await connection.InvokeAsync("SendFileListingError", requestId, $"Path not found: {target}");
        }
    }
    catch (Exception ex)
    {
        await connection.InvokeAsync("SendFileListingError", requestId, ex.Message);
    }
});

// ---- Handler: Download file (max 10MB) ----
connection.On<string, string>("DownloadFile", async (requestId, path) =>
{
    try
    {
        if (!File.Exists(path))
        {
            await connection.InvokeAsync("SendFileContent", requestId, "", "", $"File not found: {path}");
            return;
        }
        var info = new FileInfo(path);
        if (info.Length > 10 * 1024 * 1024)
        {
            await connection.InvokeAsync("SendFileContent", requestId, "", "", "File too large to download (max 10 MB)");
            return;
        }
        var bytes = await File.ReadAllBytesAsync(path);
        var base64 = Convert.ToBase64String(bytes);
        await connection.InvokeAsync("SendFileContent", requestId, info.Name, base64, "");
    }
    catch (Exception ex)
    {
        await connection.InvokeAsync("SendFileContent", requestId, "", "", ex.Message);
    }
});

// ---- Handler: Start remote desktop streaming (~12 FPS JPEG) ----
connection.On<int>("StartRemoteDesktop", (quality) =>
{
    rdCts?.Cancel();
    rdCts = new CancellationTokenSource();
    var token = rdCts.Token;
    _ = Task.Run(async () =>
    {
        while (!token.IsCancellationRequested)
        {
            try
            {
                if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    await connection.InvokeAsync("RemoteDesktopError", "Remote desktop requires Windows");
                    break;
                }
                var (w, h) = NativeWin32.GetScreenSize();
                var frame = CaptureScreen(quality);
                await connection.InvokeAsync("SendRemoteFrame", frame, w, h);
                await Task.Delay(80, token);
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                try { await connection.InvokeAsync("RemoteDesktopError", ex.Message); } catch { }
                await Task.Delay(2000, token).ContinueWith(_ => { });
            }
        }
    }, token);
});

connection.On("StopRemoteDesktop", () =>
{
    rdCts?.Cancel();
    rdCts = null;
});

// ---- Handler: Mouse events from dashboard ----
connection.On<string, int, int, int, int>("RemoteMouseEvent", (eventType, x, y, button, delta) =>
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
    try
    {
        NativeWin32.SetCursorPos(x, y);
        if (eventType == "down") NativeWin32.SendMouseButton(button, true);
        else if (eventType == "up") NativeWin32.SendMouseButton(button, false);
        else if (eventType == "scroll") NativeWin32.SendMouseWheel(delta);
    }
    catch { }
});

// ---- Handler: Keyboard events from dashboard ----
connection.On<int, bool>("RemoteKeyEvent", (vkCode, down) =>
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
    try { NativeWin32.SendKey((ushort)vkCode, down); }
    catch { }
});

connection.Reconnected += async _ =>
{
    rdCts?.Cancel();
    rdCts = null;
    await RegisterAgent(connection, agentId);
};

// Connect with retry loop
while (true)
{
    try
    {
        await connection.StartAsync();
        await RegisterAgent(connection, agentId);
        break;
    }
    catch
    {
        await Task.Delay(5000);
    }
}

var cts = new CancellationTokenSource();

// Main metrics loop (every 5 seconds)
while (!cts.Token.IsCancellationRequested)
{
    try
    {
        if (connection.State == HubConnectionState.Connected)
        {
            var metrics = CollectMetrics(agentId);
            await connection.InvokeAsync("SendMetrics", metrics, cts.Token);
        }
    }
    catch { }
    await Task.Delay(5000, cts.Token).ContinueWith(_ => { });
}

await connection.StopAsync();

// ---- Helper: Register agent with server ----
static async Task RegisterAgent(HubConnection conn, string agentId)
{
    var hostname = System.Net.Dns.GetHostName();
    var os = RuntimeInformation.OSDescription;
    var arch = RuntimeInformation.OSArchitecture.ToString();
    var cpuCount = Environment.ProcessorCount;
    var totalMem = GetTotalMemoryMb();
    var ip = GetLocalIp();

    await conn.InvokeAsync("RegisterAgent", new
    {
        AgentId = agentId,
        Hostname = hostname,
        OS = os,
        IpAddress = ip,
        Architecture = arch,
        ProcessorCount = cpuCount,
        TotalMemoryMb = totalMem
    });
}

// ---- Helper: Collect all metrics ----
static object CollectMetrics(string agentId)
{
    var cpu = GetCpuPercent();
    var (memUsed, memTotal) = GetMemoryMb();
    var (diskUsed, diskTotal) = GetDiskGb();
    var (netSent, netRecv) = GetNetworkBytes();
    var uptime = GetUptimeSeconds();
    double memPercent = memTotal > 0 ? (double)memUsed / memTotal * 100 : 0;
    double diskPercent = diskTotal > 0 ? (double)diskUsed / diskTotal * 100 : 0;

    return new
    {
        AgentId = agentId,
        CpuPercent = cpu,
        MemoryUsedMb = memUsed,
        MemoryTotalMb = memTotal,
        MemoryPercent = Math.Round(memPercent, 1),
        DiskUsedGb = diskUsed,
        DiskTotalGb = diskTotal,
        DiskPercent = Math.Round(diskPercent, 1),
        NetworkBytesSentTotal = netSent,
        NetworkBytesRecvTotal = netRecv,
        UptimeSeconds = uptime
    };
}

static double GetCpuPercent()
{
    try
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            var lines1 = File.ReadAllLines("/proc/stat");
            var parts1 = lines1[0].Split(' ', StringSplitOptions.RemoveEmptyEntries);
            long idle1 = long.Parse(parts1[4]);
            long total1 = parts1.Skip(1).Take(8).Sum(long.Parse);
            Thread.Sleep(200);
            var lines2 = File.ReadAllLines("/proc/stat");
            var parts2 = lines2[0].Split(' ', StringSplitOptions.RemoveEmptyEntries);
            long idle2 = long.Parse(parts2[4]);
            long total2 = parts2.Skip(1).Take(8).Sum(long.Parse);
            long dIdle = idle2 - idle1;
            long dTotal = total2 - total1;
            return dTotal == 0 ? 0 : Math.Round((1.0 - (double)dIdle / dTotal) * 100, 1);
        }
    }
    catch { }
    return 0;
}

static (long used, long total) GetMemoryMb()
{
    try
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            var lines = File.ReadAllLines("/proc/meminfo");
            long Parse(string key) => long.Parse(lines.First(l => l.StartsWith(key))
                .Split(':', StringSplitOptions.TrimEntries)[1]
                .Split(' ')[0]);
            long total = Parse("MemTotal") / 1024;
            long avail = Parse("MemAvailable") / 1024;
            return (total - avail, total);
        }
    }
    catch { }
    var gcInfo = GC.GetGCMemoryInfo();
    long t = gcInfo.TotalAvailableMemoryBytes / 1024 / 1024;
    long u = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
    return (u, t);
}

static (long used, long total) GetDiskGb()
{
    try
    {
        var drive = DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady && d.DriveType == DriveType.Fixed);
        if (drive != null)
        {
            long total = drive.TotalSize / 1024 / 1024 / 1024;
            long free = drive.TotalFreeSpace / 1024 / 1024 / 1024;
            return (total - free, total);
        }
    }
    catch { }
    return (0, 0);
}

static (long sent, long recv) GetNetworkBytes()
{
    try
    {
        long sent = 0, recv = 0;
        foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
        {
            if (iface.OperationalStatus == OperationalStatus.Up)
            {
                var stats = iface.GetIPv4Statistics();
                sent += stats.BytesSent;
                recv += stats.BytesReceived;
            }
        }
        return (sent, recv);
    }
    catch { }
    return (0, 0);
}

static double GetUptimeSeconds()
{
    try
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            var content = File.ReadAllText("/proc/uptime").Split(' ')[0];
            return double.Parse(content, System.Globalization.CultureInfo.InvariantCulture);
        }
        return Environment.TickCount64 / 1000.0;
    }
    catch { }
    return 0;
}

static long GetTotalMemoryMb()
{
    try
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            var lines = File.ReadAllLines("/proc/meminfo");
            var line = lines.First(l => l.StartsWith("MemTotal"));
            return long.Parse(line.Split(':', StringSplitOptions.TrimEntries)[1].Split(' ')[0]) / 1024;
        }
    }
    catch { }
    return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024;
}

static string GetDefaultRoot()
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        return "C:\\";
    return "/";
}

// ---- Screen capture (Windows only) ----
static string CaptureScreen(int quality)
{
    int w = NativeWin32.GetSystemMetrics(0);
    int h = NativeWin32.GetSystemMetrics(1);
    using var bmp = new System.Drawing.Bitmap(w, h, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
    using var g = System.Drawing.Graphics.FromImage(bmp);
    g.CopyFromScreen(0, 0, 0, 0, new System.Drawing.Size(w, h), System.Drawing.CopyPixelOperation.SourceCopy);
    using var ms = new MemoryStream();
    var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
    var ep = new System.Drawing.Imaging.EncoderParameters(1);
    ep.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)quality);
    bmp.Save(ms, codec, ep);
    return Convert.ToBase64String(ms.ToArray());
}

// ---- Register in Windows startup (runs on user login) ----
static void RegisterStartup()
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
    try
    {
        var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName ?? "";
        if (string.IsNullOrEmpty(exePath)) return;
        using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(
            @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true);
        key?.SetValue("RmmAgent", $"\"{exePath}\"");
    }
    catch { }
}

static string GetLocalIp()
{
    try
    {
        foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
        {
            if (iface.OperationalStatus == OperationalStatus.Up && iface.NetworkInterfaceType != NetworkInterfaceType.Loopback)
            {
                foreach (var addr in iface.GetIPProperties().UnicastAddresses)
                {
                    if (addr.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
                        return addr.Address.ToString();
                }
            }
        }
    }
    catch { }
    return "unknown";
}

// ---- Windows P/Invoke (mouse, keyboard, screen, console) ----
static class NativeWin32
{
    const uint INPUT_MOUSE = 0;
    const uint INPUT_KEYBOARD = 1;
    const uint KEYEVENTF_KEYUP = 0x0002;
    const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
    const uint MOUSEEVENTF_LEFTUP = 0x0004;
    const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
    const uint MOUSEEVENTF_RIGHTUP = 0x0010;
    const uint MOUSEEVENTF_MIDDLEDOWN = 0x0020;
    const uint MOUSEEVENTF_MIDDLEUP = 0x0040;
    const uint MOUSEEVENTF_WHEEL = 0x0800;

    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr dwExtraInfo; }

    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    struct KEYBDINPUT { public ushort wVk, wScan; public uint dwFlags, time; public IntPtr dwExtraInfo; }

    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
    struct InputUnion
    {
        [System.Runtime.InteropServices.FieldOffset(0)] public MOUSEINPUT mi;
        [System.Runtime.InteropServices.FieldOffset(0)] public KEYBDINPUT ki;
    }

    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    struct INPUT { public uint type; public InputUnion U; }

    [System.Runtime.InteropServices.DllImport("user32.dll")] static extern uint SendInput(uint n, INPUT[] i, int cb);
    [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
    [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern int GetSystemMetrics(int n);
    [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool AllocConsole();
    [System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern bool FreeConsole();

    public static (int w, int h) GetScreenSize() => (GetSystemMetrics(0), GetSystemMetrics(1));

    public static void SendMouseButton(int button, bool down)
    {
        uint df = button switch
        {
            0 => down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP,
            2 => down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP,
            1 => down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP,
            _ => 0
        };
        if (df == 0) return;
        var inp = new INPUT { type = INPUT_MOUSE };
        inp.U.mi.dwFlags = df;
        SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf<INPUT>());
    }

    public static void SendMouseWheel(int delta)
    {
        var inp = new INPUT { type = INPUT_MOUSE };
        inp.U.mi.dwFlags = MOUSEEVENTF_WHEEL;
        inp.U.mi.mouseData = (uint)delta;
        SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf<INPUT>());
    }

    public static void SendKey(ushort vkCode, bool down)
    {
        var inp = new INPUT { type = INPUT_KEYBOARD };
        inp.U.ki.wVk = vkCode;
        inp.U.ki.dwFlags = down ? 0u : KEYEVENTF_KEYUP;
        SendInput(1, new[] { inp }, System.Runtime.InteropServices.Marshal.SizeOf<INPUT>());
    }
}

================================================================================
FILE: src/RmmServer/RmmServer.csproj
================================================================================

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
  </ItemGroup>
</Project>

================================================================================
FILE: src/RmmServer/Program.cs
================================================================================

using Microsoft.EntityFrameworkCore;
using RmmServer.Data;
using RmmServer.Hubs;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.UseUrls("http://0.0.0.0:5000");

builder.Services.AddDbContext<RmmDbContext>(opt =>
    opt.UseSqlite("Data Source=rmm.db"));

builder.Services.AddSignalR(o =>
{
    o.EnableDetailedErrors = true;
    o.MaximumReceiveMessageSize = 10 * 1024 * 1024; // 10MB for file transfers
});
builder.Services.AddControllers();

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<RmmDbContext>();
    db.Database.EnsureCreated();
}

app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.MapHub<AgentHub>("/hubs/agent");

app.Run();

================================================================================
FILE: src/RmmServer/Data/RmmDbContext.cs
================================================================================

using Microsoft.EntityFrameworkCore;
using RmmServer.Models;

namespace RmmServer.Data;

public class RmmDbContext : DbContext
{
    public RmmDbContext(DbContextOptions<RmmDbContext> options) : base(options) { }

    public DbSet<Device> Devices => Set<Device>();
    public DbSet<MetricSnapshot> Metrics => Set<MetricSnapshot>();
    public DbSet<CommandRecord> Commands => Set<CommandRecord>();
    public DbSet<Alert> Alerts => Set<Alert>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Device>().HasIndex(d => d.AgentId).IsUnique();
        mb.Entity<MetricSnapshot>().HasOne(m => m.Device).WithMany(d => d.Metrics).HasForeignKey(m => m.DeviceId);
        mb.Entity<CommandRecord>().HasOne(c => c.Device).WithMany(d => d.Commands).HasForeignKey(c => c.DeviceId);
        mb.Entity<Alert>().HasOne(a => a.Device).WithMany(d => d.Alerts).HasForeignKey(a => a.DeviceId);
    }
}

================================================================================
FILE: src/RmmServer/Models/Device.cs
================================================================================

using System.ComponentModel.DataAnnotations;

namespace RmmServer.Models;

public class Device
{
    public int Id { get; set; }
    [Required]
    public string AgentId { get; set; } = "";
    public string Hostname { get; set; } = "";
    public string OS { get; set; } = "";
    public string IpAddress { get; set; } = "";
    public string Architecture { get; set; } = "";
    public int ProcessorCount { get; set; }
    public long TotalMemoryMb { get; set; }
    public bool IsOnline { get; set; }
    public DateTime LastSeen { get; set; }
    public DateTime RegisteredAt { get; set; }
    public ICollection<MetricSnapshot> Metrics { get; set; } = new List<MetricSnapshot>();
    public ICollection<CommandRecord> Commands { get; set; } = new List<CommandRecord>();
    public ICollection<Alert> Alerts { get; set; } = new List<Alert>();
}

================================================================================
FILE: src/RmmServer/Models/MetricSnapshot.cs
================================================================================

namespace RmmServer.Models;

public class MetricSnapshot
{
    public int Id { get; set; }
    public int DeviceId { get; set; }
    public Device Device { get; set; } = null!;
    public DateTime Timestamp { get; set; }
    public double CpuPercent { get; set; }
    public long MemoryUsedMb { get; set; }
    public long MemoryTotalMb { get; set; }
    public double MemoryPercent { get; set; }
    public long DiskUsedGb { get; set; }
    public long DiskTotalGb { get; set; }
    public double DiskPercent { get; set; }
    public long NetworkBytesSentTotal { get; set; }
    public long NetworkBytesRecvTotal { get; set; }
    public double UptimeSeconds { get; set; }
}

================================================================================
FILE: src/RmmServer/Models/Alert.cs
================================================================================

namespace RmmServer.Models;

public class Alert
{
    public int Id { get; set; }
    public int DeviceId { get; set; }
    public Device Device { get; set; } = null!;
    public string Severity { get; set; } = "info";  // "info", "warning", "critical"
    public string Message { get; set; } = "";
    public bool IsRead { get; set; }
    public DateTime CreatedAt { get; set; }
}

================================================================================
FILE: src/RmmServer/Models/CommandRecord.cs
================================================================================

namespace RmmServer.Models;

public class CommandRecord
{
    public int Id { get; set; }
    public int DeviceId { get; set; }
    public Device Device { get; set; } = null!;
    public string CommandText { get; set; } = "";
    public string? Output { get; set; }
    public string? ErrorOutput { get; set; }
    public int? ExitCode { get; set; }
    public string Status { get; set; } = "pending";  // "pending", "completed"
    public DateTime CreatedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
}

================================================================================
FILE: src/RmmServer/Hubs/AgentHub.cs
================================================================================

using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using RmmServer.Data;
using RmmServer.Models;

namespace RmmServer.Hubs;

public class AgentHub : Hub
{
    private readonly RmmDbContext _db;
    private readonly ILogger<AgentHub> _log;
    private static readonly Dictionary<string, string> _agentConnections = new();
    private static readonly Dictionary<string, string> _rdSessions = new(); // agentId -> dashboardConnId

    public AgentHub(RmmDbContext db, ILogger<AgentHub> log)
    {
        _db = db;
        _log = log;
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key;
        if (agentId != null)
        {
            _agentConnections.Remove(agentId);
            var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId);
            if (device != null)
            {
                device.IsOnline = false;
                await _db.SaveChangesAsync();
            }
            await Clients.Group("dashboard").SendAsync("DeviceOffline", agentId);
        }
        await base.OnDisconnectedAsync(exception);
    }

    public async Task RegisterAgent(AgentRegistration reg)
    {
        var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == reg.AgentId);
        if (device == null)
        {
            device = new Device { AgentId = reg.AgentId, RegisteredAt = DateTime.UtcNow };
            _db.Devices.Add(device);
        }

        device.Hostname = reg.Hostname;
        device.OS = reg.OS;
        device.IpAddress = reg.IpAddress;
        device.Architecture = reg.Architecture;
        device.ProcessorCount = reg.ProcessorCount;
        device.TotalMemoryMb = reg.TotalMemoryMb;
        device.IsOnline = true;
        device.LastSeen = DateTime.UtcNow;

        await _db.SaveChangesAsync();

        _agentConnections[reg.AgentId] = Context.ConnectionId;
        await Groups.AddToGroupAsync(Context.ConnectionId, $"agent_{reg.AgentId}");
        await Clients.Group("dashboard").SendAsync("DeviceOnline", device.AgentId, device.Hostname, device.OS, device.IpAddress);
    }

    public async Task SendMetrics(MetricPayload payload)
    {
        var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == payload.AgentId);
        if (device == null) return;

        device.LastSeen = DateTime.UtcNow;
        device.IsOnline = true;

        var snap = new MetricSnapshot
        {
            DeviceId = device.Id,
            Timestamp = DateTime.UtcNow,
            CpuPercent = payload.CpuPercent,
            MemoryUsedMb = payload.MemoryUsedMb,
            MemoryTotalMb = payload.MemoryTotalMb,
            MemoryPercent = payload.MemoryPercent,
            DiskUsedGb = payload.DiskUsedGb,
            DiskTotalGb = payload.DiskTotalGb,
            DiskPercent = payload.DiskPercent,
            NetworkBytesSentTotal = payload.NetworkBytesSentTotal,
            NetworkBytesRecvTotal = payload.NetworkBytesRecvTotal,
            UptimeSeconds = payload.UptimeSeconds
        };

        _db.Metrics.Add(snap);

        // Keep only 30 minutes of history
        var cutoff = DateTime.UtcNow.AddMinutes(-30);
        var old = _db.Metrics.Where(m => m.DeviceId == device.Id && m.Timestamp < cutoff);
        _db.Metrics.RemoveRange(old);

        await CheckAlerts(device, snap);
        await _db.SaveChangesAsync();
        await Clients.Group("dashboard").SendAsync("MetricsUpdate", payload);
    }

    public async Task SendCommandResult(CommandResultPayload result)
    {
        var cmd = await _db.Commands.FirstOrDefaultAsync(c => c.Id == result.CommandId);
        if (cmd == null) return;
        cmd.Output = result.Output;
        cmd.ErrorOutput = result.Error;
        cmd.ExitCode = result.ExitCode;
        cmd.Status = "completed";
        cmd.CompletedAt = DateTime.UtcNow;
        await _db.SaveChangesAsync();
        await Clients.Group("dashboard").SendAsync("CommandResult", result);
    }

    public async Task JoinDashboard()
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, "dashboard");
    }

    public async Task SendCommandToAgent(string agentId, int commandId, string commandText)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("ExecuteCommand", commandId, commandText);
    }

    // ---- File browser relay methods ----
    public async Task RequestFileListing(string agentId, string requestId, string path)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("BrowseDirectory", requestId, path);
        else
            await Clients.Caller.SendAsync("FileListingError", requestId, "Agent is offline");
    }

    public async Task SendFileListing(string requestId, string path, object entries)
        => await Clients.Group("dashboard").SendAsync("FileListing", requestId, path, entries);

    public async Task SendFileListingError(string requestId, string error)
        => await Clients.Group("dashboard").SendAsync("FileListingError", requestId, error);

    public async Task RequestFileDownload(string agentId, string requestId, string path)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("DownloadFile", requestId, path);
        else
            await Clients.Caller.SendAsync("FileDownloadError", requestId, "Agent is offline");
    }

    public async Task SendFileContent(string requestId, string fileName, string base64Content, string error)
        => await Clients.Group("dashboard").SendAsync("FileContent", requestId, fileName, base64Content, error);

    // ---- Remote desktop relay methods ----
    public async Task StartRemoteDesktop(string agentId, int quality)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
        {
            _rdSessions[agentId] = Context.ConnectionId;
            await Clients.Client(connId).SendAsync("StartRemoteDesktop", quality);
        }
        else
        {
            await Clients.Caller.SendAsync("RemoteDesktopError", agentId, "Agent is offline");
        }
    }

    public async Task StopRemoteDesktop(string agentId)
    {
        _rdSessions.Remove(agentId);
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("StopRemoteDesktop");
    }

    public async Task SendRemoteFrame(string frameData, int width, int height)
    {
        var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key;
        if (agentId != null && _rdSessions.TryGetValue(agentId, out var dashConnId))
            await Clients.Client(dashConnId).SendAsync("RemoteFrame", frameData, width, height);
    }

    public async Task RemoteDesktopError(string error)
    {
        var agentId = _agentConnections.FirstOrDefault(kv => kv.Value == Context.ConnectionId).Key;
        if (agentId != null && _rdSessions.TryGetValue(agentId, out var dashConnId))
            await Clients.Client(dashConnId).SendAsync("RemoteDesktopError", agentId, error);
    }

    public async Task SendRemoteMouseEvent(string agentId, string eventType, int x, int y, int button, int delta)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("RemoteMouseEvent", eventType, x, y, button, delta);
    }

    public async Task SendRemoteKeyEvent(string agentId, int vkCode, bool down)
    {
        if (_agentConnections.TryGetValue(agentId, out var connId))
            await Clients.Client(connId).SendAsync("RemoteKeyEvent", vkCode, down);
    }

    // ---- Alert generation ----
    private async Task CheckAlerts(Device device, MetricSnapshot snap)
    {
        if (snap.CpuPercent >= 90)
            _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "critical", Message = $"CPU usage is {snap.CpuPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow });
        else if (snap.CpuPercent >= 75)
            _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "warning", Message = $"High CPU usage: {snap.CpuPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow });

        if (snap.MemoryPercent >= 90)
            _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "critical", Message = $"Memory usage is {snap.MemoryPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow });

        if (snap.DiskPercent >= 90)
            _db.Alerts.Add(new Alert { DeviceId = device.Id, Severity = "warning", Message = $"Disk usage is {snap.DiskPercent:F1}% on {device.Hostname}", CreatedAt = DateTime.UtcNow });

        await Task.CompletedTask;
    }
}

// ---- Hub payload classes ----
public class AgentRegistration
{
    public string AgentId { get; set; } = "";
    public string Hostname { get; set; } = "";
    public string OS { get; set; } = "";
    public string IpAddress { get; set; } = "";
    public string Architecture { get; set; } = "";
    public int ProcessorCount { get; set; }
    public long TotalMemoryMb { get; set; }
}

public class MetricPayload
{
    public string AgentId { get; set; } = "";
    public double CpuPercent { get; set; }
    public long MemoryUsedMb { get; set; }
    public long MemoryTotalMb { get; set; }
    public double MemoryPercent { get; set; }
    public long DiskUsedGb { get; set; }
    public long DiskTotalGb { get; set; }
    public double DiskPercent { get; set; }
    public long NetworkBytesSentTotal { get; set; }
    public long NetworkBytesRecvTotal { get; set; }
    public double UptimeSeconds { get; set; }
}

public class CommandResultPayload
{
    public int CommandId { get; set; }
    public string Output { get; set; } = "";
    public string Error { get; set; } = "";
    public int ExitCode { get; set; }
}

================================================================================
FILE: src/RmmServer/Controllers/DevicesController.cs
================================================================================

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RmmServer.Data;

namespace RmmServer.Controllers;

[ApiController]
[Route("api/[controller]")]
public class DevicesController : ControllerBase
{
    private readonly RmmDbContext _db;
    public DevicesController(RmmDbContext db) => _db = db;

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var devices = await _db.Devices.Select(d => new
        {
            d.Id, d.AgentId, d.Hostname, d.OS, d.IpAddress,
            d.Architecture, d.ProcessorCount, d.TotalMemoryMb,
            d.IsOnline, d.LastSeen, d.RegisteredAt
        }).ToListAsync();
        return Ok(devices);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var device = await _db.Devices.Select(d => new
        {
            d.Id, d.AgentId, d.Hostname, d.OS, d.IpAddress,
            d.Architecture, d.ProcessorCount, d.TotalMemoryMb,
            d.IsOnline, d.LastSeen, d.RegisteredAt
        }).FirstOrDefaultAsync(d => d.Id == id);
        if (device == null) return NotFound();
        return Ok(device);
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var device = await _db.Devices.FindAsync(id);
        if (device == null) return NotFound();
        _db.Devices.Remove(device);
        await _db.SaveChangesAsync();
        return NoContent();
    }

    [HttpDelete("offline")]
    public async Task<IActionResult> DeleteOffline()
    {
        var offline = await _db.Devices.Where(d => !d.IsOnline).ToListAsync();
        _db.Devices.RemoveRange(offline);
        await _db.SaveChangesAsync();
        return Ok(new { removed = offline.Count });
    }
}

================================================================================
FILE: src/RmmServer/Controllers/AlertsController.cs
================================================================================

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RmmServer.Data;

namespace RmmServer.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AlertsController : ControllerBase
{
    private readonly RmmDbContext _db;
    public AlertsController(RmmDbContext db) => _db = db;

    [HttpGet]
    public async Task<IActionResult> GetAll([FromQuery] bool unreadOnly = false)
    {
        var query = _db.Alerts.Include(a => a.Device).AsQueryable();
        if (unreadOnly) query = query.Where(a => !a.IsRead);

        var alerts = await query
            .OrderByDescending(a => a.CreatedAt)
            .Take(100)
            .Select(a => new
            {
                a.Id, a.Severity, a.Message, a.IsRead, a.CreatedAt,
                DeviceHostname = a.Device.Hostname,
                AgentId = a.Device.AgentId
            })
            .ToListAsync();

        return Ok(alerts);
    }

    [HttpPatch("{id}/read")]
    public async Task<IActionResult> MarkRead(int id)
    {
        var alert = await _db.Alerts.FindAsync(id);
        if (alert == null) return NotFound();
        alert.IsRead = true;
        await _db.SaveChangesAsync();
        return NoContent();
    }

    [HttpPost("mark-all-read")]
    public async Task<IActionResult> MarkAllRead()
    {
        await _db.Alerts.Where(a => !a.IsRead).ExecuteUpdateAsync(s => s.SetProperty(a => a.IsRead, true));
        return NoContent();
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var alert = await _db.Alerts.FindAsync(id);
        if (alert == null) return NotFound();
        _db.Alerts.Remove(alert);
        await _db.SaveChangesAsync();
        return NoContent();
    }
}

================================================================================
FILE: src/RmmServer/Controllers/CommandsController.cs
================================================================================

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using RmmServer.Data;
using RmmServer.Hubs;
using RmmServer.Models;

namespace RmmServer.Controllers;

[ApiController]
[Route("api/[controller]")]
public class CommandsController : ControllerBase
{
    private readonly RmmDbContext _db;
    private readonly IHubContext<AgentHub> _hub;

    public CommandsController(RmmDbContext db, IHubContext<AgentHub> hub)
    {
        _db = db;
        _hub = hub;
    }

    [HttpPost]
    public async Task<IActionResult> Send([FromBody] SendCommandRequest req)
    {
        var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == req.AgentId);
        if (device == null) return NotFound("Device not found");
        if (!device.IsOnline) return BadRequest("Device is offline");

        var cmd = new CommandRecord
        {
            DeviceId = device.Id,
            CommandText = req.Command,
            Status = "pending",
            CreatedAt = DateTime.UtcNow
        };
        _db.Commands.Add(cmd);
        await _db.SaveChangesAsync();

        await _hub.Clients.Group($"agent_{req.AgentId}").SendAsync("ExecuteCommand", cmd.Id, req.Command);
        return Ok(new { cmd.Id, cmd.Status, cmd.CreatedAt });
    }

    [HttpGet("{agentId}")]
    public async Task<IActionResult> GetHistory(string agentId, [FromQuery] int count = 20)
    {
        var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId);
        if (device == null) return NotFound();

        var commands = await _db.Commands
            .Where(c => c.DeviceId == device.Id)
            .OrderByDescending(c => c.CreatedAt)
            .Take(count)
            .Select(c => new
            {
                c.Id, c.CommandText, c.Output, c.ErrorOutput,
                c.ExitCode, c.Status, c.CreatedAt, c.CompletedAt
            })
            .ToListAsync();

        return Ok(commands);
    }
}

public class SendCommandRequest
{
    public string AgentId { get; set; } = "";
    public string Command { get; set; } = "";
}

================================================================================
FILE: src/RmmServer/Controllers/MetricsController.cs
================================================================================

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RmmServer.Data;

namespace RmmServer.Controllers;

[ApiController]
[Route("api/[controller]")]
public class MetricsController : ControllerBase
{
    private readonly RmmDbContext _db;
    public MetricsController(RmmDbContext db) => _db = db;

    [HttpGet("{agentId}")]
    public async Task<IActionResult> GetLatest(string agentId, [FromQuery] int count = 60)
    {
        var device = await _db.Devices.FirstOrDefaultAsync(d => d.AgentId == agentId);
        if (device == null) return NotFound();

        var metrics = await _db.Metrics
            .Where(m => m.DeviceId == device.Id)
            .OrderByDescending(m => m.Timestamp)
            .Take(count)
            .Select(m => new
            {
                m.Timestamp, m.CpuPercent, m.MemoryUsedMb, m.MemoryTotalMb,
                m.MemoryPercent, m.DiskUsedGb, m.DiskTotalGb, m.DiskPercent,
                m.NetworkBytesSentTotal, m.NetworkBytesRecvTotal, m.UptimeSeconds
            })
            .ToListAsync();

        return Ok(metrics.OrderBy(m => m.Timestamp));
    }
}

================================================================================
API ENDPOINTS SUMMARY
================================================================================

Devices:
  GET    /api/devices              - list all devices
  GET    /api/devices/{id}         - get one device
  DELETE /api/devices/{id}         - remove one device
  DELETE /api/devices/offline      - remove all offline devices

Alerts:
  GET    /api/alerts               - list alerts (?unreadOnly=true)
  PATCH  /api/alerts/{id}/read     - mark one as read
  POST   /api/alerts/mark-all-read - mark all as read
  DELETE /api/alerts/{id}          - delete one alert

Commands:
  POST   /api/commands             - send command to agent (body: {agentId, command})
  GET    /api/commands/{agentId}   - get command history

Metrics:
  GET    /api/metrics/{agentId}    - get metric history (?count=60)

SignalR Hub:  /hubs/agent

================================================================================
SIGNALR MESSAGES - Server -> Dashboard
================================================================================

  DeviceOnline(agentId, hostname, os, ipAddress)
  DeviceOffline(agentId)
  MetricsUpdate(payload)
  CommandResult(payload)
  FileListing(requestId, path, entries)
  FileListingError(requestId, error)
  FileContent(requestId, fileName, base64Content, error)
  RemoteFrame(base64JpegData, width, height)
  RemoteDesktopError(agentId, errorMessage)

================================================================================
SIGNALR MESSAGES - Server -> Agent
================================================================================

  ExecuteCommand(commandId, commandText)
  BrowseDirectory(requestId, path)
  DownloadFile(requestId, path)
  StartRemoteDesktop(quality)   -- quality: 25/45/70
  StopRemoteDesktop()
  RemoteMouseEvent(eventType, x, y, button, delta)
  RemoteKeyEvent(vkCode, down)

================================================================================
DASHBOARD JAVASCRIPT KEY FUNCTIONS (index.html)
================================================================================

SignalR connection setup:
  const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/agent")
    .withAutomaticReconnect()
    .build();

  connection.on("MetricsUpdate", (payload) => { ... });
  connection.on("DeviceOnline", (agentId, hostname, os, ip) => { ... });
  connection.on("DeviceOffline", (agentId) => { ... });
  connection.on("CommandResult", (result) => { ... });
  connection.on("FileListing", (requestId, path, entries) => { ... });
  connection.on("FileContent", (requestId, fileName, base64, error) => { ... });
  connection.on("RemoteFrame", (frameData, width, height) => { ... });

  await connection.start();
  await connection.invoke("JoinDashboard");

Remote desktop canvas setup:
  - Canvas draws JPEG frames decoded from base64
  - Mouse events are translated to screen coordinates (canvas size vs screen resolution)
  - Keyboard events captured on canvas focus
  - Quality selector: 25 (low), 45 (medium), 70 (high)

================================================================================
HOW AGENT RUNS SILENTLY (Windows)
================================================================================

1. OutputType = WinExe   -> no console window when double-clicked
2. First run (no config):
   - AllocConsole() creates temporary console
   - User types server URL
   - URL saved to rmm-agent.conf next to the exe
   - FreeConsole() hides the window
3. All subsequent runs: completely invisible
4. RegisterStartup() writes to:
   HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
   Value: "RmmAgent" = "path\to\rmm-agent.exe"
   -> Agent auto-starts every time the user logs in

Silent install (pre-configured, no prompt):
   rmm-agent.exe https://your-server.com

NOTE: HKCU startup runs when user logs in.
For true system service (no user needed), implement as Windows Service
using Microsoft.Extensions.Hosting.WindowsServices package.

================================================================================
IMPORTANT NOTES FOR VS COPILOT
================================================================================

1. The SignalR hub uses static dictionaries for connection tracking.
   In production with multiple server instances, use Redis backplane:
   builder.Services.AddSignalR().AddStackExchangeRedis(connectionString);

2. Remote desktop (CaptureScreen) uses System.Drawing which only works on
   Windows. The agent checks RuntimeInformation.IsOSPlatform(Windows) before
   calling any screen capture or P/Invoke methods.

3. Metrics are kept for 30 minutes only (pruned on each write). For longer
   retention, add a background service that archives to a separate table.

4. SignalR MaximumReceiveMessageSize is set to 10MB to allow file transfers
   and screen frames. Tune this based on your network.

5. The dashboard is a single HTML file with vanilla JS (no framework).
   Uses Chart.js for graphs, SignalR JS client from CDN.

6. To add authentication: add ASP.NET Core Identity or JWT middleware.
   Dashboard hub calls can be protected with [Authorize] attribute.

================================================================================
END OF REFERENCE
================================================================================
