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 @@ +