Add dashboard

This commit is contained in:
Stone_Red
2026-01-05 17:24:46 +01:00
parent dee9f92277
commit ffc79d3c27
14 changed files with 627 additions and 0 deletions
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,46 @@
@inherits LayoutComponentBase
<MudThemeProvider Theme="customTheme" IsDarkMode="true" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Dashboard" Color="Color.Inherit" Edge="Edge.Start" />
<MudText Typo="Typo.h5" Class="ml-3">RemoteExec Dashboard</MudText>
<MudSpacer />
<MudText Typo="Typo.body2">Real-time Server Metrics</MudText>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.False" Class="mt-4">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@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)",
}
};
}
@@ -0,0 +1,217 @@
@page "/"
@using RemoteExec.Dashboard.Services
@using RemoteExec.Dashboard.Models
@using RemoteExec.Shared.Models
@inject MetricsCollectorService MetricsCollector
@implements IDisposable
<PageTitle>RemoteExec Dashboard</PageTitle>
<MudGrid>
@foreach (ServerConnection server in servers.Values.OrderBy(s => s.Name))
{
<MudItem xs="12" md="6" xl="4">
<MudCard Elevation="3" Class="mb-4" Style="height: 100%;">
<MudCardHeader>
<MudStack Style="width: 100%; min-width: 0;" Spacing="0">
<div class="d-flex align-center justify-space-between" style="width: 100%; min-width: 0;">
<MudText Typo="Typo.h6" Style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
@server.Name
</MudText>
@if (server.IsConnected)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Icon="@Icons.Material.Filled.CheckCircle" Style="flex-shrink: 0; margin-left: 8px;">Connected</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Icon="@Icons.Material.Filled.Cancel" Style="flex-shrink: 0; margin-left: 8px;">Disconnected</MudChip>
}
</div>
<MudText Typo="Typo.body2" Style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;">@server.Url</MudText>
</MudStack>
</MudCardHeader>
<MudCardContent>
@if (server.CurrentMetrics != null)
{
<MudGrid>
<MudItem xs="6">
<MudPaper Elevation="0" Class="d-flex flex-column align-center pa-2">
<MudIcon Icon="@Icons.Material.Filled.Link" Size="Size.Large" Color="Color.Primary" />
<MudText Typo="Typo.h6">@server.CurrentMetrics.ActiveConnections</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Connections</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6">
<MudPaper Elevation="0" Class="d-flex flex-column align-center pa-2">
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Large" Color="Color.Success" />
<MudText Typo="Typo.h6">@server.CurrentMetrics.ActiveTasks</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Active Tasks</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6">
<MudPaper Elevation="0" Class="d-flex flex-column align-center pa-2">
<MudIcon Icon="@Icons.Material.Filled.Memory" Size="Size.Large" Color="Color.Info" />
<MudText Typo="Typo.h6">@FormatBytes(server.CurrentMetrics.TotalMemoryUsage)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Memory</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6">
<MudPaper Elevation="0" Class="d-flex flex-column align-center pa-2">
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Large" Color="Color.Warning" />
<MudText Typo="Typo.h6">@server.CurrentMetrics.CpuUsage.ToString("F1")%</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">CPU Usage</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudText Typo="Typo.caption" Class="mt-3">
Last updated: @server.CurrentMetrics.Timestamp.ToLocalTime().ToString("HH:mm:ss")
</MudText>
}
else if (!string.IsNullOrEmpty(server.LastError))
{
<MudAlert Severity="Severity.Error">@server.LastError</MudAlert>
}
else
{
<MudProgressCircular Indeterminate="true" />
<MudText>Connecting...</MudText>
}
<MudDivider Class="my-4" />
<MudExpansionPanels>
<MudExpansionPanel Text="Historical Charts" IsInitiallyExpanded="false">
@if (server.MetricsHistory.Count > 1)
{
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-2">CPU Usage</MudText>
<MudChart ChartType="ChartType.Line"
ChartSeries="@GetCpuChartSeries(server)"
XAxisLabels="@GetChartLabels(server)"
Width="100%"
Height="350px"
ChartOptions="@chartOptions" />
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">Memory Usage</MudText>
<MudChart ChartType="ChartType.Line"
ChartSeries="@GetMemoryChartSeries(server)"
XAxisLabels="@GetChartLabels(server)"
Width="100%"
Height="350px"
ChartOptions="@chartOptions" />
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">Active Tasks</MudText>
<MudChart ChartType="ChartType.Line"
ChartSeries="@GetTasksChartSeries(server)"
XAxisLabels="@GetChartLabels(server)"
Width="100%"
Height="350px"
ChartOptions="@chartOptions" />
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-4">Waiting for historical data...</MudText>
}
</MudExpansionPanel>
</MudExpansionPanels>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@if (!servers.Any())
{
<MudAlert Severity="Severity.Warning">No servers configured. Please add server configurations to appsettings.json.</MudAlert>
}
@code {
private Dictionary<string, ServerConnection> 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<ChartSeries> GetCpuChartSeries(ServerConnection server)
{
return new List<ChartSeries>
{
new ChartSeries
{
Name = "CPU %",
Data = server.MetricsHistory.Select(m => m.CpuUsage).ToArray()
}
};
}
private List<ChartSeries> GetMemoryChartSeries(ServerConnection server)
{
return new List<ChartSeries>
{
new ChartSeries
{
Name = "Memory MB",
Data = server.MetricsHistory.Select(m => m.TotalMemoryUsage / 1024.0 / 1024.0).ToArray()
}
};
}
private List<ChartSeries> GetTasksChartSeries(ServerConnection server)
{
return new List<ChartSeries>
{
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;
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -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
@@ -0,0 +1,17 @@
namespace RemoteExec.Dashboard.Configuration;
/// <summary>
/// Configuration for the dashboard.
/// </summary>
public class DashboardConfiguration
{
/// <summary>
/// Gets or sets the list of servers to monitor.
/// </summary>
public List<ServerConfiguration> Servers { get; set; } = [];
/// <summary>
/// Gets or sets the maximum number of metrics history entries to keep per server.
/// </summary>
public int MaxMetricsHistoryCount { get; set; } = 100;
}
@@ -0,0 +1,22 @@
namespace RemoteExec.Dashboard.Configuration;
/// <summary>
/// Configuration for a single server connection.
/// </summary>
public class ServerConfiguration
{
/// <summary>
/// Gets or sets the display name of the server.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the URL of the server.
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the API key for authentication.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
}
@@ -0,0 +1,39 @@
using RemoteExec.Shared.Models;
namespace RemoteExec.Dashboard.Models;
/// <summary>
/// Represents a server connection with its metrics history.
/// </summary>
public class ServerConnection
{
/// <summary>
/// Gets or sets the display name of the server.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the URL of the server.
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether the server is currently connected.
/// </summary>
public bool IsConnected { get; set; }
/// <summary>
/// Gets or sets the current metrics for the server.
/// </summary>
public ServerMetrics? CurrentMetrics { get; set; }
/// <summary>
/// Gets or sets the historical metrics data.
/// </summary>
public List<ServerMetrics> MetricsHistory { get; set; } = [];
/// <summary>
/// Gets or sets the last error message.
/// </summary>
public string? LastError { get; set; }
}
+32
View File
@@ -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<DashboardConfiguration>(builder.Configuration.GetSection("Dashboard"));
builder.Services.AddSingleton<MetricsCollectorService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MetricsCollectorService>());
WebApplication app = builder.Build();
if (!app.Environment.IsDevelopment())
{
_ = app.UseExceptionHandler("/Error");
_ = app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<RemoteExec.Dashboard.Components.App>()
.AddInteractiveServerRenderMode();
await app.RunAsync();
@@ -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"
}
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RemoteExec.Shared\RemoteExec.Shared.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Background service that collects metrics from configured servers.
/// </summary>
public class MetricsCollectorService : BackgroundService
{
private readonly ILogger<MetricsCollectorService> logger;
private readonly DashboardConfiguration configuration;
private readonly ConcurrentDictionary<string, ServerConnection> servers = new();
private readonly ConcurrentDictionary<string, HubConnection> connections = new();
public event EventHandler<ServerConnection>? MetricsUpdated;
public MetricsCollectorService(ILogger<MetricsCollectorService> logger, IOptions<DashboardConfiguration> options)
{
this.logger = logger;
configuration = options.Value;
}
public IReadOnlyDictionary<string, ServerConnection> 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<ServerMetrics>("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<ServerMetrics>("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);
}
}
+27
View File
@@ -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
}
}
+1
View File
@@ -1,6 +1,7 @@
<Solution> <Solution>
<Project Path="RemoteExec.Client.DependencyInjection/RemoteExec.Client.DependencyInjection.csproj" Id="e9895828-9e57-475e-98ea-2afff271b7e9" /> <Project Path="RemoteExec.Client.DependencyInjection/RemoteExec.Client.DependencyInjection.csproj" Id="e9895828-9e57-475e-98ea-2afff271b7e9" />
<Project Path="RemoteExec.Client/RemoteExec.Client.csproj" Id="8ac1533b-f69a-4b1b-bb3a-fb381cd4bb7b" /> <Project Path="RemoteExec.Client/RemoteExec.Client.csproj" Id="8ac1533b-f69a-4b1b-bb3a-fb381cd4bb7b" />
<Project Path="RemoteExec.Dashboard/RemoteExec.Dashboard.csproj" />
<Project Path="RemoteExec.Generators/RemoteExec.Generators.csproj" Id="71a394a7-22f4-4c59-b91f-744c48161696" /> <Project Path="RemoteExec.Generators/RemoteExec.Generators.csproj" Id="71a394a7-22f4-4c59-b91f-744c48161696" />
<Project Path="RemoteExec.Server/RemoteExec.Server.csproj" Id="f88cb8bb-8221-4e5e-880a-0c58e1255602" /> <Project Path="RemoteExec.Server/RemoteExec.Server.csproj" Id="f88cb8bb-8221-4e5e-880a-0c58e1255602" />
<Project Path="RemoteExec.Shared/RemoteExec.Shared.csproj" Id="99317a9c-c0ad-42a2-a830-56aa9c6b9dd4" /> <Project Path="RemoteExec.Shared/RemoteExec.Shared.csproj" Id="99317a9c-c0ad-42a2-a830-56aa9c6b9dd4" />