From ffc79d3c27227a0f3021c2bed8cc067a1238d5e7 Mon Sep 17 00:00:00 2001
From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com>
Date: Mon, 5 Jan 2026 17:23:33 +0100
Subject: [PATCH] Add dashboard
---
src/RemoteExec.Dashboard/Components/App.razor | 20 ++
.../Components/Layout/MainLayout.razor | 46 ++++
.../Components/Pages/Home.razor | 217 ++++++++++++++++++
.../Components/Routes.razor | 6 +
.../Components/_Imports.razor | 10 +
.../Configuration/DashboardConfiguration.cs | 17 ++
.../Configuration/ServerConfiguration.cs | 22 ++
.../Models/ServerConnection.cs | 39 ++++
src/RemoteExec.Dashboard/Program.cs | 32 +++
.../Properties/launchSettings.json | 22 ++
.../RemoteExec.Dashboard.csproj | 18 ++
.../Services/MetricsCollectorService.cs | 150 ++++++++++++
src/RemoteExec.Dashboard/appsettings.json | 27 +++
src/RemoteExec.slnx | 1 +
14 files changed, 627 insertions(+)
create mode 100644 src/RemoteExec.Dashboard/Components/App.razor
create mode 100644 src/RemoteExec.Dashboard/Components/Layout/MainLayout.razor
create mode 100644 src/RemoteExec.Dashboard/Components/Pages/Home.razor
create mode 100644 src/RemoteExec.Dashboard/Components/Routes.razor
create mode 100644 src/RemoteExec.Dashboard/Components/_Imports.razor
create mode 100644 src/RemoteExec.Dashboard/Configuration/DashboardConfiguration.cs
create mode 100644 src/RemoteExec.Dashboard/Configuration/ServerConfiguration.cs
create mode 100644 src/RemoteExec.Dashboard/Models/ServerConnection.cs
create mode 100644 src/RemoteExec.Dashboard/Program.cs
create mode 100644 src/RemoteExec.Dashboard/Properties/launchSettings.json
create mode 100644 src/RemoteExec.Dashboard/RemoteExec.Dashboard.csproj
create mode 100644 src/RemoteExec.Dashboard/Services/MetricsCollectorService.cs
create mode 100644 src/RemoteExec.Dashboard/appsettings.json
diff --git a/src/RemoteExec.Dashboard/Components/App.razor b/src/RemoteExec.Dashboard/Components/App.razor
new file mode 100644
index 0000000..57a55c7
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Components/App.razor
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RemoteExec.Dashboard/Components/Layout/MainLayout.razor b/src/RemoteExec.Dashboard/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..31fc46e
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Components/Layout/MainLayout.razor
@@ -0,0 +1,46 @@
+@inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+ RemoteExec Dashboard
+
+ Real-time Server Metrics
+
+
+
+ @Body
+
+
+
+
+@code {
+ private MudTheme customTheme = new MudTheme()
+ {
+ PaletteLight = new PaletteLight()
+ {
+ Primary = "#594ae2",
+ Secondary = "#ff4081",
+ AppbarBackground = "#594ae2",
+ },
+ PaletteDark = new PaletteDark()
+ {
+ Primary = "#776be7",
+ Secondary = "#ff4081",
+ AppbarBackground = "#27272f",
+ Background = "#1e1e2d",
+ BackgroundGray = "#27272f",
+ Surface = "#27272f",
+ DrawerBackground = "#27272f",
+ DrawerText = "rgba(255,255,255, 0.7)",
+ ActionDefault = "#adadb1",
+ ActionDisabled = "rgba(255,255,255, 0.26)",
+ ActionDisabledBackground = "rgba(255,255,255, 0.12)",
+ }
+ };
+}
diff --git a/src/RemoteExec.Dashboard/Components/Pages/Home.razor b/src/RemoteExec.Dashboard/Components/Pages/Home.razor
new file mode 100644
index 0000000..02251ba
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Components/Pages/Home.razor
@@ -0,0 +1,217 @@
+@page "/"
+@using RemoteExec.Dashboard.Services
+@using RemoteExec.Dashboard.Models
+@using RemoteExec.Shared.Models
+@inject MetricsCollectorService MetricsCollector
+@implements IDisposable
+
+RemoteExec Dashboard
+
+
+ @foreach (ServerConnection server in servers.Values.OrderBy(s => s.Name))
+ {
+
+
+
+
+
+
+ @server.Name
+
+ @if (server.IsConnected)
+ {
+ Connected
+ }
+ else
+ {
+ Disconnected
+ }
+
+
+ @server.Url
+
+
+
+ @if (server.CurrentMetrics != null)
+ {
+
+
+
+
+ @server.CurrentMetrics.ActiveConnections
+ Connections
+
+
+
+
+
+ @server.CurrentMetrics.ActiveTasks
+ Active Tasks
+
+
+
+
+
+ @FormatBytes(server.CurrentMetrics.TotalMemoryUsage)
+ Memory
+
+
+
+
+
+ @server.CurrentMetrics.CpuUsage.ToString("F1")%
+ CPU Usage
+
+
+
+
+
+ Last updated: @server.CurrentMetrics.Timestamp.ToLocalTime().ToString("HH:mm:ss")
+
+ }
+ else if (!string.IsNullOrEmpty(server.LastError))
+ {
+ @server.LastError
+ }
+ else
+ {
+
+ Connecting...
+ }
+
+
+
+
+
+ @if (server.MetricsHistory.Count > 1)
+ {
+ CPU Usage
+
+
+ Memory Usage
+
+
+ Active Tasks
+
+ }
+ else
+ {
+ Waiting for historical data...
+ }
+
+
+
+
+
+ }
+
+
+@if (!servers.Any())
+{
+ No servers configured. Please add server configurations to appsettings.json.
+}
+
+@code {
+ private Dictionary servers = new();
+ private readonly ChartOptions chartOptions = new ChartOptions
+ {
+ YAxisTicks = 5,
+ MaxNumYAxisTicks = 10,
+ YAxisLines = true,
+ XAxisLines = false,
+ };
+
+ protected override void OnInitialized()
+ {
+ servers = MetricsCollector.GetServers().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ MetricsCollector.MetricsUpdated += OnMetricsUpdated;
+ }
+
+ private void OnMetricsUpdated(object? sender, ServerConnection server)
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ private List GetCpuChartSeries(ServerConnection server)
+ {
+ return new List
+ {
+ new ChartSeries
+ {
+ Name = "CPU %",
+ Data = server.MetricsHistory.Select(m => m.CpuUsage).ToArray()
+ }
+ };
+ }
+
+ private List GetMemoryChartSeries(ServerConnection server)
+ {
+ return new List
+ {
+ new ChartSeries
+ {
+ Name = "Memory MB",
+ Data = server.MetricsHistory.Select(m => m.TotalMemoryUsage / 1024.0 / 1024.0).ToArray()
+ }
+ };
+ }
+
+ private List GetTasksChartSeries(ServerConnection server)
+ {
+ return new List
+ {
+ new ChartSeries
+ {
+ Name = "Active",
+ Data = server.MetricsHistory.Select(m => (double)m.ActiveTasks).ToArray()
+ },
+ new ChartSeries
+ {
+ Name = "Max",
+ Data = server.MetricsHistory.Select(m => (double)m.MaxConcurrentTasks).ToArray()
+ }
+ };
+ }
+
+ private string[] GetChartLabels(ServerConnection server)
+ {
+ return server.MetricsHistory.Select((m, i) =>
+ i % Math.Max(1, server.MetricsHistory.Count / 10) == 0
+ ? m.Timestamp.ToLocalTime().ToString("HH:mm:ss")
+ : string.Empty
+ ).ToArray();
+ }
+
+ private string FormatBytes(long bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ double len = bytes;
+ int order = 0;
+ while (len >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ len /= 1024;
+ }
+ return $"{len:0.##} {sizes[order]}";
+ }
+
+ public void Dispose()
+ {
+ MetricsCollector.MetricsUpdated -= OnMetricsUpdated;
+ }
+}
diff --git a/src/RemoteExec.Dashboard/Components/Routes.razor b/src/RemoteExec.Dashboard/Components/Routes.razor
new file mode 100644
index 0000000..faa2a8c
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/RemoteExec.Dashboard/Components/_Imports.razor b/src/RemoteExec.Dashboard/Components/_Imports.razor
new file mode 100644
index 0000000..ac1ef17
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Components/_Imports.razor
@@ -0,0 +1,10 @@
+@using System.Net.Http.Headers
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.JSInterop
+@using RemoteExec.Dashboard
+@using RemoteExec.Dashboard.Components
+@using RemoteExec.Dashboard.Components.Layout
+@using MudBlazor
diff --git a/src/RemoteExec.Dashboard/Configuration/DashboardConfiguration.cs b/src/RemoteExec.Dashboard/Configuration/DashboardConfiguration.cs
new file mode 100644
index 0000000..1559228
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Configuration/DashboardConfiguration.cs
@@ -0,0 +1,17 @@
+namespace RemoteExec.Dashboard.Configuration;
+
+///
+/// Configuration for the dashboard.
+///
+public class DashboardConfiguration
+{
+ ///
+ /// Gets or sets the list of servers to monitor.
+ ///
+ public List Servers { get; set; } = [];
+
+ ///
+ /// Gets or sets the maximum number of metrics history entries to keep per server.
+ ///
+ public int MaxMetricsHistoryCount { get; set; } = 100;
+}
diff --git a/src/RemoteExec.Dashboard/Configuration/ServerConfiguration.cs b/src/RemoteExec.Dashboard/Configuration/ServerConfiguration.cs
new file mode 100644
index 0000000..eaea83e
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Configuration/ServerConfiguration.cs
@@ -0,0 +1,22 @@
+namespace RemoteExec.Dashboard.Configuration;
+
+///
+/// Configuration for a single server connection.
+///
+public class ServerConfiguration
+{
+ ///
+ /// Gets or sets the display name of the server.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the URL of the server.
+ ///
+ public string Url { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the API key for authentication.
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+}
diff --git a/src/RemoteExec.Dashboard/Models/ServerConnection.cs b/src/RemoteExec.Dashboard/Models/ServerConnection.cs
new file mode 100644
index 0000000..2c86951
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Models/ServerConnection.cs
@@ -0,0 +1,39 @@
+using RemoteExec.Shared.Models;
+
+namespace RemoteExec.Dashboard.Models;
+
+///
+/// Represents a server connection with its metrics history.
+///
+public class ServerConnection
+{
+ ///
+ /// Gets or sets the display name of the server.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the URL of the server.
+ ///
+ public string Url { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets whether the server is currently connected.
+ ///
+ public bool IsConnected { get; set; }
+
+ ///
+ /// Gets or sets the current metrics for the server.
+ ///
+ public ServerMetrics? CurrentMetrics { get; set; }
+
+ ///
+ /// Gets or sets the historical metrics data.
+ ///
+ public List MetricsHistory { get; set; } = [];
+
+ ///
+ /// Gets or sets the last error message.
+ ///
+ public string? LastError { get; set; }
+}
diff --git a/src/RemoteExec.Dashboard/Program.cs b/src/RemoteExec.Dashboard/Program.cs
new file mode 100644
index 0000000..57087f9
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Program.cs
@@ -0,0 +1,32 @@
+using MudBlazor.Services;
+using RemoteExec.Dashboard.Configuration;
+using RemoteExec.Dashboard.Services;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+
+builder.Services.AddMudServices();
+
+builder.Services.Configure(builder.Configuration.GetSection("Dashboard"));
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
+
+WebApplication app = builder.Build();
+
+if (!app.Environment.IsDevelopment())
+{
+ _ = app.UseExceptionHandler("/Error");
+ _ = app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+app.UseAntiforgery();
+
+app.MapStaticAssets();
+app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+
+await app.RunAsync();
diff --git a/src/RemoteExec.Dashboard/Properties/launchSettings.json b/src/RemoteExec.Dashboard/Properties/launchSettings.json
new file mode 100644
index 0000000..36b72fe
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Properties/launchSettings.json
@@ -0,0 +1,22 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5100",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7100;http://localhost:5100",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/RemoteExec.Dashboard/RemoteExec.Dashboard.csproj b/src/RemoteExec.Dashboard/RemoteExec.Dashboard.csproj
new file mode 100644
index 0000000..d95625a
--- /dev/null
+++ b/src/RemoteExec.Dashboard/RemoteExec.Dashboard.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RemoteExec.Dashboard/Services/MetricsCollectorService.cs b/src/RemoteExec.Dashboard/Services/MetricsCollectorService.cs
new file mode 100644
index 0000000..6cb2c7a
--- /dev/null
+++ b/src/RemoteExec.Dashboard/Services/MetricsCollectorService.cs
@@ -0,0 +1,150 @@
+using Microsoft.AspNetCore.SignalR.Client;
+using Microsoft.Extensions.Options;
+
+using RemoteExec.Dashboard.Configuration;
+using RemoteExec.Dashboard.Models;
+using RemoteExec.Shared.Models;
+
+using System.Collections.Concurrent;
+
+namespace RemoteExec.Dashboard.Services;
+
+///
+/// Background service that collects metrics from configured servers.
+///
+public class MetricsCollectorService : BackgroundService
+{
+ private readonly ILogger logger;
+ private readonly DashboardConfiguration configuration;
+ private readonly ConcurrentDictionary servers = new();
+ private readonly ConcurrentDictionary connections = new();
+
+ public event EventHandler? MetricsUpdated;
+
+ public MetricsCollectorService(ILogger logger, IOptions options)
+ {
+ this.logger = logger;
+ configuration = options.Value;
+ }
+
+ public IReadOnlyDictionary GetServers()
+ {
+ return servers;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ foreach (ServerConfiguration serverConfig in configuration.Servers)
+ {
+ ServerConnection server = new ServerConnection
+ {
+ Name = serverConfig.Name,
+ Url = serverConfig.Url,
+ IsConnected = false
+ };
+
+ _ = servers.TryAdd(serverConfig.Url, server);
+ _ = Task.Run(() => ConnectToServerAsync(serverConfig, server, stoppingToken), stoppingToken);
+ }
+
+ await Task.Delay(Timeout.Infinite, stoppingToken);
+ }
+
+ private async Task ConnectToServerAsync(Configuration.ServerConfiguration serverConfig, ServerConnection server, CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ HubConnection? connection = connections.GetOrAdd(serverConfig.Url, __ =>
+ {
+ HubConnection conn = new HubConnectionBuilder()
+ .WithUrl(serverConfig.Url, options =>
+ {
+ options.Headers.Add("X-API-Key", serverConfig.ApiKey);
+ })
+ .WithAutomaticReconnect()
+ .Build();
+
+ _ = conn.On("MetricsUpdated", metrics =>
+ {
+ UpdateServerMetrics(server, metrics);
+ });
+
+ conn.Closed += async error =>
+ {
+ server.IsConnected = false;
+ server.LastError = error?.Message;
+ MetricsUpdated?.Invoke(this, server);
+ logger.LogWarning("Connection to {ServerName} closed. Error: {Error}", server.Name, error?.Message);
+ };
+
+ conn.Reconnecting += error =>
+ {
+ server.IsConnected = false;
+ server.LastError = error?.Message;
+ MetricsUpdated?.Invoke(this, server);
+ logger.LogInformation("Reconnecting to {ServerName}...", server.Name);
+ return Task.CompletedTask;
+ };
+
+ conn.Reconnected += connectionId =>
+ {
+ server.IsConnected = true;
+ server.LastError = null;
+ MetricsUpdated?.Invoke(this, server);
+ logger.LogInformation("Reconnected to {ServerName}", server.Name);
+ return Task.CompletedTask;
+ };
+
+ return conn;
+ });
+
+ if (connection.State == HubConnectionState.Disconnected)
+ {
+ await connection.StartAsync(cancellationToken);
+ server.IsConnected = true;
+ server.LastError = null;
+ logger.LogInformation("Connected to {ServerName} at {Url}", server.Name, serverConfig.Url);
+
+ ServerMetrics initialMetrics = await connection.InvokeAsync("GetMetrics", cancellationToken);
+ UpdateServerMetrics(server, initialMetrics);
+ }
+
+ break;
+ }
+ catch (Exception ex)
+ {
+ server.IsConnected = false;
+ server.LastError = ex.Message;
+ MetricsUpdated?.Invoke(this, server);
+ logger.LogError(ex, "Error connecting to {ServerName} at {Url}. Retrying in 5 seconds...", server.Name, serverConfig.Url);
+ await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
+ }
+ }
+ }
+
+ private void UpdateServerMetrics(ServerConnection server, ServerMetrics metrics)
+ {
+ server.CurrentMetrics = metrics;
+ server.MetricsHistory.Add(metrics);
+
+ if (server.MetricsHistory.Count > configuration.MaxMetricsHistoryCount)
+ {
+ server.MetricsHistory.RemoveAt(0);
+ }
+
+ MetricsUpdated?.Invoke(this, server);
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ foreach (HubConnection connection in connections.Values)
+ {
+ await connection.StopAsync(cancellationToken);
+ await connection.DisposeAsync();
+ }
+
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/src/RemoteExec.Dashboard/appsettings.json b/src/RemoteExec.Dashboard/appsettings.json
new file mode 100644
index 0000000..6c7cbd2
--- /dev/null
+++ b/src/RemoteExec.Dashboard/appsettings.json
@@ -0,0 +1,27 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+
+ // Dashboard configuration
+ "Dashboard": {
+ // List of RemoteExec servers to monitor
+ "Servers": [
+ // Example:
+ // {
+ // "Name": "Production Server",
+ // "Url": "https://server.example.com/remote",
+ // "ApiKey": "your-api-key-here"
+ // }
+ ],
+
+ // Maximum number of historical metrics data points to keep per server
+ // Higher values = longer history, more memory usage
+ // Default: 100
+ "MaxMetricsHistoryCount": 100
+ }
+}
diff --git a/src/RemoteExec.slnx b/src/RemoteExec.slnx
index f003ff0..f669ac2 100644
--- a/src/RemoteExec.slnx
+++ b/src/RemoteExec.slnx
@@ -1,6 +1,7 @@
+