From ee0f30f0004b9001f346d6b5a99781dff928a95c Mon Sep 17 00:00:00 2001 From: Fleny Date: Sun, 12 May 2024 12:35:09 +0200 Subject: [PATCH] Refactor to avoid internals in HexusApplication --- Hexus.Daemon/AppJsonSerializerContext.cs | 4 +- .../Configuration/HexusApplication.cs | 26 +- ...tionResponse.cs => ApplicationResponse.cs} | 2 +- .../Applications/DeleteApplicationEndpoint.cs | 9 +- .../Applications/EditApplicationEndpoint.cs | 3 +- .../Applications/GetApplicationEndpoint.cs | 8 +- .../Endpoints/Applications/GetLogsEndpoint.cs | 190 +------------ .../Applications/ListApplicationsEndpoint.cs | 9 +- .../Applications/NewApplicationEndpoint.cs | 14 +- .../RestartApplicationEndpoint.cs | 2 +- .../Applications/SendInputEndpoint.cs | 11 +- .../Applications/StartApplicationEndpoint.cs | 13 +- .../Applications/StopApplicationEndpoint.cs | 12 +- Hexus.Daemon/Extensions/MapperExtensions.cs | 26 +- Hexus.Daemon/Extensions/ProcessExtensions.cs | 12 +- Hexus.Daemon/HexusDaemon.cs | 2 + Hexus.Daemon/Services/HexusLifecycle.cs | 24 +- Hexus.Daemon/Services/LogService.cs | 251 ++++++++++++++++++ .../Services/PerformanceTrackingService.cs | 86 +----- .../Services/ProcessManagerService.cs | 125 ++++----- .../Services/ProcessStatisticsService.cs | 159 +++++++++++ Hexus/Commands/Applications/InfoCommand.cs | 2 +- Hexus/Commands/Applications/ListCommand.cs | 2 +- 23 files changed, 571 insertions(+), 421 deletions(-) rename Hexus.Daemon/Contracts/Responses/{HexusApplicationResponse.cs => ApplicationResponse.cs} (88%) create mode 100644 Hexus.Daemon/Services/LogService.cs create mode 100644 Hexus.Daemon/Services/ProcessStatisticsService.cs diff --git a/Hexus.Daemon/AppJsonSerializerContext.cs b/Hexus.Daemon/AppJsonSerializerContext.cs index 8f57c0f..2f5f7c0 100644 --- a/Hexus.Daemon/AppJsonSerializerContext.cs +++ b/Hexus.Daemon/AppJsonSerializerContext.cs @@ -6,8 +6,8 @@ namespace Hexus.Daemon; [JsonSerializable(typeof(HttpValidationProblemDetails))] -[JsonSerializable(typeof(HexusApplicationResponse))] -[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(ApplicationResponse))] +[JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(IAsyncEnumerable))] [JsonSerializable(typeof(NewApplicationRequest))] [JsonSerializable(typeof(EditApplicationRequest))] diff --git a/Hexus.Daemon/Configuration/HexusApplication.cs b/Hexus.Daemon/Configuration/HexusApplication.cs index 8e16d20..3f85f17 100644 --- a/Hexus.Daemon/Configuration/HexusApplication.cs +++ b/Hexus.Daemon/Configuration/HexusApplication.cs @@ -1,8 +1,4 @@ -using Hexus.Daemon.Contracts; -using System.ComponentModel; -using System.Diagnostics; -using System.Threading.Channels; -using YamlDotNet.Serialization; +using System.ComponentModel; namespace Hexus.Daemon.Configuration; @@ -17,24 +13,4 @@ public sealed record HexusApplication [DefaultValue("")] public string Note { get; set; } = ""; public Dictionary EnvironmentVariables { get; set; } = []; - - #region Internal proprieties - - [YamlIgnore] internal Process? Process { get; set; } - - // Logs - [YamlIgnore] internal SemaphoreSlim LogSemaphore { get; } = new(initialCount: 1, maxCount: 1); - [YamlIgnore] internal List> LogChannels { get; } = []; - - // Performance tracking - [YamlIgnore] internal Dictionary CpuStatsMap { get; } = []; - [YamlIgnore] internal double LastCpuUsage { get; set; } - - internal record CpuStats - { - public TimeSpan LastTotalProcessorTime { get; set; } - public DateTimeOffset LastGetProcessCpuUsageInvocation { get; set; } - } - - #endregion } diff --git a/Hexus.Daemon/Contracts/Responses/HexusApplicationResponse.cs b/Hexus.Daemon/Contracts/Responses/ApplicationResponse.cs similarity index 88% rename from Hexus.Daemon/Contracts/Responses/HexusApplicationResponse.cs rename to Hexus.Daemon/Contracts/Responses/ApplicationResponse.cs index c2f61bf..214994c 100644 --- a/Hexus.Daemon/Contracts/Responses/HexusApplicationResponse.cs +++ b/Hexus.Daemon/Contracts/Responses/ApplicationResponse.cs @@ -2,7 +2,7 @@ namespace Hexus.Daemon.Contracts.Responses; -public sealed record HexusApplicationResponse( +public sealed record ApplicationResponse( string Name, string Executable, string Arguments, diff --git a/Hexus.Daemon/Endpoints/Applications/DeleteApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/DeleteApplicationEndpoint.cs index 664b0bd..c578e11 100644 --- a/Hexus.Daemon/Endpoints/Applications/DeleteApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/DeleteApplicationEndpoint.cs @@ -12,16 +12,17 @@ internal sealed class DeleteApplicationEndpoint : IEndpoint public static Results Handle( [FromServices] HexusConfigurationManager configManager, [FromServices] ProcessManagerService processManager, + [FromServices] LogService logService, [FromRoute] string name, [FromQuery] bool forceStop = false) { - if (!configManager.Configuration.Applications.ContainsKey(name)) + if (!configManager.Configuration.Applications.TryGetValue(name, out var application)) return TypedResults.NotFound(); - processManager.StopApplication(name, forceStop); + processManager.StopApplication(application, forceStop); + logService.DeleteApplication(application); - File.Delete($"{EnvironmentHelper.LogsDirectory}/{name}.log"); - configManager.Configuration.Applications.Remove(name); + configManager.Configuration.Applications.Remove(name, out _); configManager.SaveConfiguration(); return TypedResults.NoContent(); diff --git a/Hexus.Daemon/Endpoints/Applications/EditApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/EditApplicationEndpoint.cs index 48f83a6..42458ab 100644 --- a/Hexus.Daemon/Endpoints/Applications/EditApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/EditApplicationEndpoint.cs @@ -18,12 +18,13 @@ public static Results Handle( [FromRoute] string name, [FromBody] EditApplicationRequest request, [FromServices] IValidator validator, + [FromServices] ProcessManagerService processManagerService, [FromServices] HexusConfigurationManager configurationManager) { if (!configurationManager.Configuration.Applications.TryGetValue(name, out var application)) return TypedResults.NotFound(); - if (ProcessManagerService.IsApplicationRunning(application)) + if (processManagerService.IsApplicationRunning(application, out _)) return TypedResults.ValidationProblem(ErrorResponses.ApplicationRunningWhileEditing); if (request.Name is not null && configurationManager.Configuration.Applications.TryGetValue(request.Name, out _)) diff --git a/Hexus.Daemon/Endpoints/Applications/GetApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/GetApplicationEndpoint.cs index b81d469..e177e4b 100644 --- a/Hexus.Daemon/Endpoints/Applications/GetApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/GetApplicationEndpoint.cs @@ -2,6 +2,7 @@ using Hexus.Daemon.Configuration; using Hexus.Daemon.Contracts.Responses; using Hexus.Daemon.Extensions; +using Hexus.Daemon.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -10,13 +11,14 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class GetApplicationEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Get, "/{name}")] - public static Results, NotFound> Handle( + public static Results, NotFound> Handle( [FromRoute] string name, - [FromServices] HexusConfiguration configuration) + [FromServices] HexusConfiguration configuration, + [FromServices] ProcessStatisticsService processStatisticsService) { if (!configuration.Applications.TryGetValue(name, out var application)) return TypedResults.NotFound(); - return TypedResults.Ok(application.MapToResponse()); + return TypedResults.Ok(application.MapToResponse(processStatisticsService.GetApplicationStats(application))); } } diff --git a/Hexus.Daemon/Endpoints/Applications/GetLogsEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/GetLogsEndpoint.cs index 6e4bc68..00013f1 100644 --- a/Hexus.Daemon/Endpoints/Applications/GetLogsEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/GetLogsEndpoint.cs @@ -4,19 +4,15 @@ using Hexus.Daemon.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -using System.Globalization; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Channels; namespace Hexus.Daemon.Endpoints.Applications; -internal partial class GetLogsEndpoint : IEndpoint +internal sealed class GetLogsEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Get, "/{name}/logs")] public static Results>, NotFound> Handle( [FromServices] HexusConfiguration configuration, - [FromServices] ILogger logger, + [FromServices] LogService logService, [FromRoute] string name, [FromQuery] int lines = 100, [FromQuery] bool noStreaming = false, @@ -33,186 +29,8 @@ public static Results>, NotFound> Handle( // If the before is in the past we can disable steaming if (before is not null && before < DateTimeOffset.UtcNow) noStreaming = true; - return TypedResults.Ok(GetLogs(application, logger, lines, noStreaming, before, after, combinedCtSource.Token)); - } - - private static async IAsyncEnumerable GetLogs( - HexusApplication application, - ILogger logger, - int lines, - bool noStreaming, - DateTimeOffset? before, - DateTimeOffset? after, - [EnumeratorCancellation] CancellationToken ct) - { - Channel? channel = null; - - if (!noStreaming) - { - channel = Channel.CreateUnbounded(); - application.LogChannels.Add(channel); - } - - try - { - var logs = GetLogs(application, logger, lines, before, after); - - foreach (var log in logs.Reverse()) - { - if (!IsLogDateInRange(log.Date, before, after)) continue; + var logs = logService.GetLogs(application, lines, !noStreaming, before, after, combinedCtSource.Token); - yield return log; - } - - if (noStreaming || channel is null) - yield break; - - await foreach (var log in channel.Reader.ReadAllAsync(ct)) - { - if (!IsLogDateInRange(log.Date, before, after)) continue; - - yield return log; - } - } - finally - { - if (channel is not null) - { - channel.Writer.Complete(); - application.LogChannels.Remove(channel); - } - } + return TypedResults.Ok(logs); } - - private static IEnumerable GetLogs(HexusApplication application, ILogger logger, int lines, DateTimeOffset? before = null, DateTimeOffset? after = null) - { - application.LogSemaphore.Wait(); - - try - { - using var stream = File.OpenRead($"{EnvironmentHelper.LogsDirectory}/{application.Name}.log"); - using var reader = new StreamReader(stream, Encoding.UTF8); - - if (stream.Length <= 2) - yield break; - - var lineFound = 0; - - // Go to the end of the file. - stream.Position = stream.Length - 1; - - // If the last character is a LF we can skip it as it isn't a log line. - if (stream.ReadByte() == '\n') - stream.Position -= 2; - - while (lineFound < lines) - { - // We are in a line, so we go back until we find the start of this line. - if (stream.Position != 0 && stream.ReadByte() != '\n') - { - if (stream.Position >= 2) - { - stream.Position -= 2; - continue; - } - - break; - } - - var positionBeforeRead = stream.Position; - - reader.DiscardBufferedData(); - var line = reader.ReadLine(); - - stream.Position = positionBeforeRead; - - if (string.IsNullOrEmpty(line)) - { - if (stream.Position >= 2) - stream.Position -= 2; - - continue; - } - - var logDateString = line[1..34]; - if (!TryLogTimeFormat(logDateString, out var logDate)) - { - LogFailedDateTimeParsing(logger, application.Name, logDateString); - - if (stream.Position >= 2) - { - stream.Position -= 2; - continue; - } - - break; - } - - if (!IsLogDateInRange(logDate, before, after)) - { - if (stream.Position >= 2) - { - stream.Position -= 2; - continue; - } - - break; - } - - lineFound++; - - var logTypeString = line[35..41]; - var logText = line[43..]; - - if (!LogType.TryParse(logTypeString.AsSpan(), out var logType)) - { - LogFailedTypeParsing(logger, application.Name, logTypeString); - - if (stream.Position >= 2) - { - stream.Position -= 2; - continue; - } - - break; - } - - yield return new ApplicationLog(logDate, logType, logText); - - if (stream.Position >= 2) - { - stream.Position -= 2; - continue; - } - - break; - } - } - finally - { - application.LogSemaphore.Release(); - } - } - - private static bool TryLogTimeFormat(ReadOnlySpan logDate, out DateTimeOffset dateTimeOffset) - { - return DateTimeOffset.TryParseExact(logDate, "O", null, DateTimeStyles.AssumeUniversal, out dateTimeOffset); - } - - private static bool IsLogDateInRange(DateTimeOffset time, DateTimeOffset? before = null, DateTimeOffset? after = null) - { - if (before is not null && time > before.Value) - return false; - - if (after is not null && time < after.Value) - return false; - - return true; - } - - [LoggerMessage(LogLevel.Warning, "There was an error parsing the log file for application {Name}: Couldn't parse \"{LogDate}\" as a DateTime. Skipping log line.")] - private static partial void LogFailedDateTimeParsing(ILogger logger, string name, string logDate); - - [LoggerMessage(LogLevel.Warning, "There was an error parsing the log file for application {Name}: Couldn't parse \"{LogType}\" as a LogType. Skipping log line.")] - private static partial void LogFailedTypeParsing(ILogger logger, string name, string logType); } diff --git a/Hexus.Daemon/Endpoints/Applications/ListApplicationsEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/ListApplicationsEndpoint.cs index 433b520..2980caf 100644 --- a/Hexus.Daemon/Endpoints/Applications/ListApplicationsEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/ListApplicationsEndpoint.cs @@ -2,6 +2,7 @@ using Hexus.Daemon.Configuration; using Hexus.Daemon.Contracts.Responses; using Hexus.Daemon.Extensions; +using Hexus.Daemon.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -10,6 +11,10 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class ListApplicationsEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Get, "/list")] - public static Ok> Handle([FromServices] HexusConfiguration config) - => TypedResults.Ok(config.Applications.MapToResponse()); + public static Ok> Handle( + [FromServices] HexusConfiguration config, + [FromServices] ProcessStatisticsService processStatisticsService) + { + return TypedResults.Ok(config.Applications.Values.MapToResponse(processStatisticsService.GetApplicationStats)); + } } diff --git a/Hexus.Daemon/Endpoints/Applications/NewApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/NewApplicationEndpoint.cs index b2e2bd1..82ed1dc 100644 --- a/Hexus.Daemon/Endpoints/Applications/NewApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/NewApplicationEndpoint.cs @@ -15,11 +15,12 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class NewApplicationEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Post, "/new")] - public static Results, ValidationProblem, StatusCodeHttpResult> Handle( + public static Results, ValidationProblem, StatusCodeHttpResult> Handle( [FromBody] NewApplicationRequest request, [FromServices] IValidator validator, [FromServices] HexusConfigurationManager configManager, - [FromServices] ProcessManagerService processManager) + [FromServices] ProcessManagerService processManager, + [FromServices] ProcessStatisticsService processStatisticsService) { // Fill some defaults that are not compile time constants, so they require to be filled in here. request = request with @@ -36,12 +37,19 @@ public static Results, ValidationProblem, StatusCod if (configManager.Configuration.Applications.TryGetValue(application.Name, out _)) return TypedResults.ValidationProblem(ErrorResponses.ApplicationAlreadyExists); + processStatisticsService.TrackApplicationUsages(application); + if (!processManager.StartApplication(application)) + { + processStatisticsService.StopTrackingApplicationUsage(application); return TypedResults.StatusCode((int)HttpStatusCode.InternalServerError); + } configManager.Configuration.Applications.Add(application.Name, application); configManager.SaveConfiguration(); - return TypedResults.Ok(application.MapToResponse()); + var stats = processStatisticsService.GetApplicationStats(application); + + return TypedResults.Ok(application.MapToResponse(stats)); } } diff --git a/Hexus.Daemon/Endpoints/Applications/RestartApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/RestartApplicationEndpoint.cs index dbf0dfd..2ccc35f 100644 --- a/Hexus.Daemon/Endpoints/Applications/RestartApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/RestartApplicationEndpoint.cs @@ -19,7 +19,7 @@ public static Results Handle( if (!configuration.Applications.TryGetValue(name, out var application)) return TypedResults.NotFound(); - processManager.StopApplication(application.Name, forceStop); + processManager.StopApplication(application, forceStop); if (!processManager.StartApplication(application)) return TypedResults.StatusCode((int)HttpStatusCode.InternalServerError); diff --git a/Hexus.Daemon/Endpoints/Applications/SendInputEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/SendInputEndpoint.cs index 1a287b4..cc9d705 100644 --- a/Hexus.Daemon/Endpoints/Applications/SendInputEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/SendInputEndpoint.cs @@ -1,5 +1,6 @@ using EndpointMapper; using FluentValidation; +using Hexus.Daemon.Configuration; using Hexus.Daemon.Contracts; using Hexus.Daemon.Contracts.Requests; using Hexus.Daemon.Extensions; @@ -12,16 +13,20 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class SendInputEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Post, "/{name}/stdin")] - public static Results Handle( + public static Results Handle( [FromRoute] string name, [FromBody] SendInputRequest request, [FromServices] IValidator validator, - [FromServices] ProcessManagerService processManager) + [FromServices] ProcessManagerService processManager, + [FromServices] HexusConfiguration configuration) { + if (!configuration.Applications.TryGetValue(name, out var application)) + return TypedResults.NotFound(); + if (!validator.Validate(request, out var validationResult)) return TypedResults.ValidationProblem(validationResult.ToDictionary()); - if (!processManager.SendToApplication(name, request.Text, request.AddNewLine)) + if (!processManager.SendToApplication(application, request.Text, request.AddNewLine)) return TypedResults.ValidationProblem(ErrorResponses.ApplicationNotRunning); return TypedResults.NoContent(); diff --git a/Hexus.Daemon/Endpoints/Applications/StartApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/StartApplicationEndpoint.cs index d7ae92e..f5ac1f1 100644 --- a/Hexus.Daemon/Endpoints/Applications/StartApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/StartApplicationEndpoint.cs @@ -1,4 +1,5 @@ using EndpointMapper; +using Hexus.Daemon.Configuration; using Hexus.Daemon.Contracts; using Hexus.Daemon.Services; using Microsoft.AspNetCore.Http.HttpResults; @@ -10,14 +11,18 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class StartApplicationEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Post, "/{name}")] - public static Results Handle( + public static Results Handle( [FromRoute] string name, - [FromServices] ProcessManagerService processManager) + [FromServices] ProcessManagerService processManager, + [FromServices] HexusConfiguration configuration) { - if (processManager.IsApplicationRunning(name, out var application)) + if (!configuration.Applications.TryGetValue(name, out var application)) + return TypedResults.NotFound(); + + if (processManager.IsApplicationRunning(application, out _)) return TypedResults.ValidationProblem(ErrorResponses.ApplicationAlreadyRunning); - if (application is null || !processManager.StartApplication(application)) + if (!processManager.StartApplication(application)) return TypedResults.StatusCode((int)HttpStatusCode.InternalServerError); return TypedResults.NoContent(); diff --git a/Hexus.Daemon/Endpoints/Applications/StopApplicationEndpoint.cs b/Hexus.Daemon/Endpoints/Applications/StopApplicationEndpoint.cs index 249ce4b..756c035 100644 --- a/Hexus.Daemon/Endpoints/Applications/StopApplicationEndpoint.cs +++ b/Hexus.Daemon/Endpoints/Applications/StopApplicationEndpoint.cs @@ -1,4 +1,5 @@ using EndpointMapper; +using Hexus.Daemon.Configuration; using Hexus.Daemon.Contracts; using Hexus.Daemon.Services; using Microsoft.AspNetCore.Http.HttpResults; @@ -9,14 +10,21 @@ namespace Hexus.Daemon.Endpoints.Applications; internal sealed class StopApplicationEndpoint : IEndpoint { [HttpMap(HttpMapMethod.Delete, "/{name}")] - public static Results Handle( + public static Results Handle( [FromServices] ProcessManagerService processManager, + [FromServices] ProcessStatisticsService processStatisticsService, + [FromServices] HexusConfiguration configuration, [FromRoute] string name, [FromQuery] bool forceStop = false) { - if (!processManager.StopApplication(name, forceStop)) + if (!configuration.Applications.TryGetValue(name, out var application)) + return TypedResults.NotFound(); + + if (!processManager.StopApplication(application, forceStop)) return TypedResults.ValidationProblem(ErrorResponses.ApplicationNotRunning); + processStatisticsService.StopTrackingApplicationUsage(application); + return TypedResults.NoContent(); } } diff --git a/Hexus.Daemon/Extensions/MapperExtensions.cs b/Hexus.Daemon/Extensions/MapperExtensions.cs index d2496ff..9074432 100644 --- a/Hexus.Daemon/Extensions/MapperExtensions.cs +++ b/Hexus.Daemon/Extensions/MapperExtensions.cs @@ -18,22 +18,24 @@ public static HexusApplication MapToApplication(this NewApplicationRequest reque EnvironmentVariables = request.EnvironmentVariables ?? [], }; - public static HexusApplicationResponse MapToResponse(this HexusApplication application) => + public static ApplicationResponse MapToResponse(this HexusApplication application, ApplicationStatistics applicationStatisticsResponse) => new( - application.Name, - EnvironmentHelper.NormalizePath(application.Executable), - application.Arguments, - Note: application.Note, + Name: application.Name, + Executable: EnvironmentHelper.NormalizePath(application.Executable), + Arguments: application.Arguments, WorkingDirectory: EnvironmentHelper.NormalizePath(application.WorkingDirectory), + Note: application.Note, EnvironmentVariables: application.EnvironmentVariables, Status: application.Status, - ProcessUptime: application.Process is { HasExited: false } ? DateTime.Now - application.Process.StartTime : TimeSpan.Zero, - ProcessId: application.Process is { HasExited: false } ? application.Process.Id : 0, - CpuUsage: application.LastCpuUsage, - MemoryUsage: PerformanceTrackingService.GetMemoryUsage(application) + ProcessUptime: applicationStatisticsResponse.ProcessUptime, + ProcessId: applicationStatisticsResponse.ProcessId, + CpuUsage: applicationStatisticsResponse.CpuUsage, + MemoryUsage: applicationStatisticsResponse.MemoryUsage ); - public static IEnumerable MapToResponse(this Dictionary applications) => - applications - .Select(pair => pair.Value.MapToResponse()); + public static IEnumerable MapToResponse(this IEnumerable applications, + Func getApplicationStats) + { + return applications.Select(app => app.MapToResponse(getApplicationStats(app))); + } } diff --git a/Hexus.Daemon/Extensions/ProcessExtensions.cs b/Hexus.Daemon/Extensions/ProcessExtensions.cs index fac6c8a..8c7ce25 100644 --- a/Hexus.Daemon/Extensions/ProcessExtensions.cs +++ b/Hexus.Daemon/Extensions/ProcessExtensions.cs @@ -1,27 +1,27 @@ -using Hexus.Daemon.Configuration; using Hexus.Daemon.Interop; +using Hexus.Daemon.Services; using System.Diagnostics; namespace Hexus.Daemon.Extensions; internal static class ProcessExtensions { - public static double GetProcessCpuUsage(this Process process, HexusApplication.CpuStats cpuStats) + public static double GetProcessCpuUsage(this Process process, ProcessStatisticsService.CpuStatistics cpuStatistics) { var currentTime = DateTimeOffset.UtcNow; - var timeDifference = currentTime - cpuStats.LastGetProcessCpuUsageInvocation; + var timeDifference = currentTime - cpuStatistics.LastGetProcessCpuUsageInvocation; // In a situation like this we are provably going to give an unreasonable number, it's better to just say 0% than 100% if (timeDifference < TimeSpan.FromMilliseconds(100)) return 0.00; var currentTotalProcessorTime = process.TotalProcessorTime; - var processorTimeDifference = currentTotalProcessorTime - cpuStats.LastTotalProcessorTime; + var processorTimeDifference = currentTotalProcessorTime - cpuStatistics.LastTotalProcessorTime; var cpuUsage = processorTimeDifference / Environment.ProcessorCount / timeDifference; - cpuStats.LastTotalProcessorTime = currentTotalProcessorTime; - cpuStats.LastGetProcessCpuUsageInvocation = currentTime; + cpuStatistics.LastTotalProcessorTime = currentTotalProcessorTime; + cpuStatistics.LastGetProcessCpuUsageInvocation = currentTime; return cpuUsage * 100; } diff --git a/Hexus.Daemon/HexusDaemon.cs b/Hexus.Daemon/HexusDaemon.cs index 8d7c696..67be688 100644 --- a/Hexus.Daemon/HexusDaemon.cs +++ b/Hexus.Daemon/HexusDaemon.cs @@ -54,6 +54,8 @@ public static void StartDaemon(string[] args) // Services & HostedServices builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); diff --git a/Hexus.Daemon/Services/HexusLifecycle.cs b/Hexus.Daemon/Services/HexusLifecycle.cs index 0cc463d..34ec3c6 100644 --- a/Hexus.Daemon/Services/HexusLifecycle.cs +++ b/Hexus.Daemon/Services/HexusLifecycle.cs @@ -2,7 +2,11 @@ namespace Hexus.Daemon.Services; -internal sealed class HexusLifecycle(HexusConfigurationManager configManager, ProcessManagerService processManager) : IHostedLifecycleService +internal sealed class HexusLifecycle( + HexusConfigurationManager configManager, + ProcessManagerService processManager, + LogService logService, + ProcessStatisticsService processStatisticsService) : IHostedLifecycleService { internal static readonly CancellationTokenSource DaemonStoppingTokenSource = new(); public static CancellationToken DaemonStoppingToken => DaemonStoppingTokenSource.Token; @@ -10,11 +14,15 @@ internal sealed class HexusLifecycle(HexusConfigurationManager configManager, Pr public Task StartedAsync(CancellationToken cancellationToken) { - var runningApplications = configManager.Configuration.Applications.Values - .Where(application => application is { Status: HexusApplicationStatus.Running }); + foreach (var application in configManager.Configuration.Applications.Values) + { + logService.PrepareApplication(application); + + if (application.Status is not HexusApplicationStatus.Running) continue; - foreach (var application in runningApplications) + processStatisticsService.TrackApplicationUsages(application); processManager.StartApplication(application); + } return Task.CompletedTask; } @@ -22,7 +30,12 @@ public Task StartedAsync(CancellationToken cancellationToken) public Task StoppedAsync(CancellationToken cancellationToken) { File.Delete(configManager.Configuration.UnixSocket); + StopApplications(processManager); + foreach (var application in configManager.Configuration.Applications.Values) + { + processStatisticsService.StopTrackingApplicationUsage(application); + } return Task.CompletedTask; } @@ -45,8 +58,7 @@ internal static void StopApplications(ProcessManagerService processManagerServic // Else we might try to stop applications that exiting lock (processManagerService) { - Parallel.ForEach(processManagerService.Applications.Values, - application => processManagerService.StopApplication(application.Name)); + processManagerService.StopApplications(); } } } diff --git a/Hexus.Daemon/Services/LogService.cs b/Hexus.Daemon/Services/LogService.cs new file mode 100644 index 0000000..8c5d8be --- /dev/null +++ b/Hexus.Daemon/Services/LogService.cs @@ -0,0 +1,251 @@ +using Hexus.Daemon.Configuration; +using Hexus.Daemon.Contracts; +using System.Collections.Concurrent; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Channels; + +namespace Hexus.Daemon.Services; + +public partial class LogService(ILogger logger) +{ + private readonly ConcurrentDictionary _logControllers = new(); + + public void ProcessApplicationLog(HexusApplication application, LogType logType, string message) + { + if (!_logControllers.TryGetValue(application, out var logController)) + { + LogUnableToGetLogController(logger, application.Name); + return; + } + + if (logType != LogType.System) + LogApplicationOutput(logger, application.Name, message); + + var applicationLog = new ApplicationLog(DateTimeOffset.UtcNow, logType, message); + + logController.Channels.ForEach(channel => channel.Writer.TryWrite(applicationLog)); + logController.Semaphore.Wait(); + + try + { + File.AppendAllText( + $"{EnvironmentHelper.LogsDirectory}/{application.Name}.log", + $"[{applicationLog.Date:O},{applicationLog.LogType.Name}] {applicationLog.Text}{Environment.NewLine}" + ); + } + finally + { + logController.Semaphore.Release(); + } + } + + public async IAsyncEnumerable GetLogs(HexusApplication application, int lines, bool streaming, + DateTimeOffset? before, DateTimeOffset? after, [EnumeratorCancellation] CancellationToken ct) + { + if (!_logControllers.TryGetValue(application, out var logController)) + { + LogUnableToGetLogController(logger, application.Name); + yield break; + } + + Channel? channel = null; + + if (streaming) + { + channel = Channel.CreateUnbounded(); + logController.Channels.Add(channel); + } + + try + { + var logs = GetLogsFromFile(application, logController, lines, before, after); + + foreach (var log in logs.Reverse()) + { + if (!IsLogDateInRange(log.Date, before, after)) continue; + + yield return log; + } + + if (!streaming || channel is null) + yield break; + + await foreach (var log in channel.Reader.ReadAllAsync(ct)) + { + if (!IsLogDateInRange(log.Date, before, after)) continue; + + yield return log; + } + } + finally + { + if (channel is not null) + { + channel.Writer.Complete(); + logController.Channels.Remove(channel); + } + } + } + + public void PrepareApplication(HexusApplication application) + { + _logControllers[application] = new LogController(); + } + + public void DeleteApplication(HexusApplication application) + { + _logControllers.TryRemove(application, out _); + File.Delete($"{EnvironmentHelper.LogsDirectory}/{application.Name}.log"); + } + + #region Log From File Parser + + private IEnumerable GetLogsFromFile(HexusApplication application, LogController logController, + int lines, DateTimeOffset? before, DateTimeOffset? after) + { + logController.Semaphore.Wait(); + + try + { + using var stream = File.OpenRead($"{EnvironmentHelper.LogsDirectory}/{application.Name}.log"); + using var reader = new StreamReader(stream, Encoding.UTF8); + + if (stream.Length <= 2) + yield break; + + var lineFound = 0; + + // Go to the end of the file. + stream.Position = stream.Length - 1; + + // If the last character is a LF we can skip it as it isn't a log line. + if (stream.ReadByte() == '\n') + stream.Position -= 2; + + while (lineFound < lines) + { + // We are in a line, so we go back until we find the start of this line. + if (stream.Position != 0 && stream.ReadByte() != '\n') + { + if (stream.Position >= 2) + { + stream.Position -= 2; + continue; + } + + break; + } + + var positionBeforeRead = stream.Position; + + reader.DiscardBufferedData(); + var line = reader.ReadLine(); + + stream.Position = positionBeforeRead; + + if (string.IsNullOrEmpty(line)) + { + if (stream.Position >= 2) + stream.Position -= 2; + + continue; + } + + var logDateString = line[1..34]; + if (!TryLogTimeFormat(logDateString, out var logDate)) + { + LogFailedDateTimeParsing(logger, application.Name, logDateString); + + if (stream.Position >= 2) + { + stream.Position -= 2; + continue; + } + + break; + } + + if (!IsLogDateInRange(logDate, before, after)) + { + if (stream.Position >= 2) + { + stream.Position -= 2; + continue; + } + + break; + } + + lineFound++; + + var logTypeString = line[35..41]; + var logText = line[43..]; + + if (!LogType.TryParse(logTypeString.AsSpan(), out var logType)) + { + LogFailedTypeParsing(logger, application.Name, logTypeString); + + if (stream.Position >= 2) + { + stream.Position -= 2; + continue; + } + + break; + } + + yield return new ApplicationLog(logDate, logType, logText); + + if (stream.Position >= 2) + { + stream.Position -= 2; + continue; + } + + break; + } + } + finally + { + logController.Semaphore.Release(); + } + } + + private static bool TryLogTimeFormat(ReadOnlySpan logDate, out DateTimeOffset dateTimeOffset) + { + return DateTimeOffset.TryParseExact(logDate, "O", null, DateTimeStyles.AssumeUniversal, out dateTimeOffset); + } + + private static bool IsLogDateInRange(DateTimeOffset time, DateTimeOffset? before = null, DateTimeOffset? after = null) + { + if (before is not null && time > before.Value) + return false; + + if (after is not null && time < after.Value) + return false; + + return true; + } + + #endregion + + [LoggerMessage(LogLevel.Warning, "There was an error parsing the log file for application {Name}: Couldn't parse \"{LogDate}\" as a DateTime. Skipping log line.")] + private static partial void LogFailedDateTimeParsing(ILogger logger, string name, string logDate); + + [LoggerMessage(LogLevel.Warning, "There was an error parsing the log file for application {Name}: Couldn't parse \"{LogType}\" as a LogType. Skipping log line.")] + private static partial void LogFailedTypeParsing(ILogger logger, string name, string logType); + + [LoggerMessage(LogLevel.Warning, "Unable to get log controller for application \"{Name}\"")] + private static partial void LogUnableToGetLogController(ILogger logger, string name); + + [LoggerMessage(LogLevel.Trace, "Application \"{Name}\" says: '{OutputData}'")] + private static partial void LogApplicationOutput(ILogger logger, string name, string outputData); + + internal record LogController + { + public SemaphoreSlim Semaphore { get; } = new(initialCount: 1, maxCount: 1); + public List> Channels { get; } = []; + } +} diff --git a/Hexus.Daemon/Services/PerformanceTrackingService.cs b/Hexus.Daemon/Services/PerformanceTrackingService.cs index 272461f..a2dae9d 100644 --- a/Hexus.Daemon/Services/PerformanceTrackingService.cs +++ b/Hexus.Daemon/Services/PerformanceTrackingService.cs @@ -1,11 +1,11 @@ using Hexus.Daemon.Configuration; -using Hexus.Daemon.Extensions; -using Hexus.Daemon.Interop; -using System.Diagnostics; namespace Hexus.Daemon.Services; -internal partial class PerformanceTrackingService(ILogger logger, HexusConfiguration configuration) : BackgroundService +internal partial class PerformanceTrackingService( + ILogger logger, + HexusConfiguration configuration, + ProcessStatisticsService processStatisticsService) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { @@ -23,7 +23,7 @@ protected override async Task ExecuteAsync(CancellationToken ct) { try { - RefreshCpuUsage(); + processStatisticsService.RefreshCpuUsage(); } catch (Exception ex) { @@ -32,82 +32,6 @@ protected override async Task ExecuteAsync(CancellationToken ct) } } - internal static long GetMemoryUsage(HexusApplication application) - { - if (application.Process is not { HasExited: false }) - return 0; - - return GetApplicationProcesses(application) - .Where(proc => proc is { HasExited: false }) - .Select(proc => - { - proc.Refresh(); - - return proc.WorkingSet64; - }) - .Sum(); - } - - private void RefreshCpuUsage() - { - var children = ProcessChildren.GetProcessChildrenInfo(Environment.ProcessId) - .GroupBy(x => x.ParentProcessId) - .ToDictionary(x => x.Key, x => x.Select(inf => inf.ProcessId).ToArray()); - - if (!children.TryGetValue(Environment.ProcessId, out var hexusChildren)) return; - - var apps = configuration.Applications.Values - .Where(app => app is { Process.HasExited: false } && hexusChildren.Contains(app.Process.Id)) - .ToDictionary(x => x.Process!.Id, x => x); - - foreach (var child in hexusChildren) - { - if (!apps.TryGetValue(child, out var app)) continue; - - var cpuUsage = Traverse(child, children).Select(proc => GetProcessCpuUsage(app, proc)).Sum(); - app.LastCpuUsage = Math.Clamp(Math.Round(cpuUsage, 2), 0, 100); - } - } - - private static IEnumerable Traverse(int processId, IReadOnlyDictionary processIds) - { - yield return Process.GetProcessById(processId); - - if (!processIds.TryGetValue(processId, out var childrenIds)) yield break; - - foreach (var child in childrenIds) - { - yield return Process.GetProcessById(child); - foreach (var childProc in Traverse(child, processIds)) yield return childProc; - } - } - - private static double GetProcessCpuUsage(HexusApplication application, Process process) - { - if (process is not { HasExited: false, Id: var processId }) - { - application.CpuStatsMap.Remove(process.Id); - return 0; - } - - var cpuStats = application.CpuStatsMap.GetOrCreate(processId, _ => new HexusApplication.CpuStats - { - LastTotalProcessorTime = TimeSpan.Zero, - LastGetProcessCpuUsageInvocation = DateTimeOffset.UtcNow, - }); - - return process.GetProcessCpuUsage(cpuStats); - } - - private static IEnumerable GetApplicationProcesses(HexusApplication application) - { - // If the process is null or has exited then return an empty list - if (application.Process is not { HasExited: false }) - return []; - - return [application.Process, ..application.Process.GetChildProcesses()]; - } - [LoggerMessage(LogLevel.Warning, "Disabling the CPU performance tracking. An invalid interval ({interval}s) was passed in.")] private static partial void LogDisablePerformanceTracking(ILogger logger, double interval); diff --git a/Hexus.Daemon/Services/ProcessManagerService.cs b/Hexus.Daemon/Services/ProcessManagerService.cs index e288945..78c3088 100644 --- a/Hexus.Daemon/Services/ProcessManagerService.cs +++ b/Hexus.Daemon/Services/ProcessManagerService.cs @@ -8,13 +8,14 @@ namespace Hexus.Daemon.Services; -internal partial class ProcessManagerService(ILogger logger, HexusConfigurationManager configManager) +internal partial class ProcessManagerService( + ILogger logger, + HexusConfigurationManager configManager, + LogService logService) { - internal ConcurrentDictionary Applications { get; } = new(); + private readonly ConcurrentDictionary _processToApplicationMap = new(); + private readonly ConcurrentDictionary _applicationToProcessMap = new(); - /// Start an instance of the application - /// The application to start - /// Whatever if the application was started or not public bool StartApplication(HexusApplication application) { var processInfo = new ProcessStartInfo @@ -42,10 +43,10 @@ public bool StartApplication(HexusApplication application) if (process is null or { HasExited: true }) return false; - application.Process = process; - Applications[process] = application; + _processToApplicationMap[process] = application; + _applicationToProcessMap[application] = process; - ProcessApplicationLog(application, LogType.System, "-- Application started --"); + logService.ProcessApplicationLog(application, LogType.System, "-- Application started --"); // Enable the emitting of events and the reading of the STDOUT and STDERR process.EnableRaisingEvents = true; @@ -65,23 +66,20 @@ public bool StartApplication(HexusApplication application) return true; } - /// Stop the instance of an application - /// The name of the application to stop - /// Force the stopping of the application via a force kill - /// If the application was running - public bool StopApplication(string name, bool forceStop = false) + public bool StopApplication(HexusApplication application, bool forceStop = false) { - if (!IsApplicationRunning(name, out var application) || application.Process is null) + if (!IsApplicationRunning(application, out var process)) return false; // Remove the restart event handler, or else it will restart the process as soon as it stops - var process = application.Process; process.Exited -= HandleProcessRestart; StopProcess(process, forceStop); application.Status = HexusApplicationStatus.Exited; - Applications.TryRemove(process, out _); + + _processToApplicationMap.TryRemove(process, out _); + _applicationToProcessMap.TryRemove(application, out _); // If the daemon is shutting down we don't want to save, or else when the daemon is booted up again, all the applications will be marked as stopped if (!HexusLifecycle.IsDaemonStopped) @@ -90,37 +88,36 @@ public bool StopApplication(string name, bool forceStop = false) return true; } - /// Given a name of an application check if it exists, is running and has an attached process running - /// The name of the application - /// The application returned with the same string - /// If the application is running - public bool IsApplicationRunning(string name, [NotNullWhen(true)] out HexusApplication? application) => - configManager.Configuration.Applications.TryGetValue(name, out application) && IsApplicationRunning(application); - - /// Check if an application exists, is running and has an attached process running - /// The nullable instance of an - /// If the application is running - public static bool IsApplicationRunning([NotNullWhen(true)] HexusApplication? application) - => application is { Status: HexusApplicationStatus.Running, Process.HasExited: false }; - - /// Send a message into the Standard Input (STDIN) of an application - /// The name of the application - /// The text to send into the STDIN - /// Whatever or not to append an \n to the text - /// Whatever or not if the operation was successful or not - public bool SendToApplication(string name, ReadOnlySpan text, bool newLine = true) + public void StopApplications() + { + Parallel.ForEach(_processToApplicationMap, tuple => StopApplication(tuple.Value)); + } + + public bool IsApplicationRunning(HexusApplication application, [NotNullWhen(true)] out Process? process) + { + if (!_applicationToProcessMap.TryGetValue(application, out process)) + { + return false; + } + + return application is { Status: HexusApplicationStatus.Running } && process is { HasExited: false }; + } + + public bool SendToApplication(HexusApplication application, ReadOnlySpan text, bool newLine = true) { - if (!IsApplicationRunning(name, out var application) || application.Process is null) + if (!IsApplicationRunning(application, out var process)) return false; if (newLine) - application.Process.StandardInput.WriteLine(text); + process.StandardInput.WriteLine(text); else - application.Process.StandardInput.Write(text); + process.StandardInput.Write(text); return true; } + #region Stop Process Internals + private void StopProcess(Process process, bool forceStop) { if (forceStop) @@ -164,45 +161,24 @@ private static void KillProcess(Process process, bool killTree = false) _ = process.HasExited; } - #region Log process events handlers - - private void ProcessApplicationLog(HexusApplication application, LogType logType, string message) - { - if (logType != LogType.System) - LogApplicationOutput(logger, application.Name, message); - - var applicationLog = new ApplicationLog(DateTimeOffset.UtcNow, logType, message); - - application.LogChannels.ForEach(channel => channel.Writer.TryWrite(applicationLog)); - application.LogSemaphore.Wait(); + #endregion - try - { - File.AppendAllText( - $"{EnvironmentHelper.LogsDirectory}/{application.Name}.log", - $"[{applicationLog.Date:O},{applicationLog.LogType.Name}] {applicationLog.Text}{Environment.NewLine}" - ); - } - finally - { - application.LogSemaphore.Release(); - } - } + #region Log process events handlers private void HandleStdOutLogs(object? sender, DataReceivedEventArgs e) { - if (sender is not Process process || e.Data is null || !Applications.TryGetValue(process, out var application)) + if (sender is not Process process || e.Data is null || !_processToApplicationMap.TryGetValue(process, out var application)) return; - ProcessApplicationLog(application, LogType.StdOut, e.Data); + logService.ProcessApplicationLog(application, LogType.StdOut, e.Data); } private void HandleStdErrLogs(object? sender, DataReceivedEventArgs e) { - if (sender is not Process process || e.Data is null || !Applications.TryGetValue(process, out var application)) + if (sender is not Process process || e.Data is null || !_processToApplicationMap.TryGetValue(process, out var application)) return; - ProcessApplicationLog(application, LogType.StdErr, e.Data); + logService.ProcessApplicationLog(application, LogType.StdErr, e.Data); } #endregion @@ -217,25 +193,21 @@ private void HandleStdErrLogs(object? sender, DataReceivedEventArgs e) private void AcknowledgeProcessExit(object? sender, EventArgs e) { - if (sender is not Process process || !Applications.TryGetValue(process, out var application)) + if (sender is not Process process || !_processToApplicationMap.TryGetValue(process, out var application)) return; var exitCode = process.ExitCode; - ProcessApplicationLog(application, LogType.System, $"-- Application stopped [Exit code: {exitCode}] --"); + logService.ProcessApplicationLog(application, LogType.System, $"-- Application stopped [Exit code: {exitCode}] --"); - application.Process?.Close(); - application.Process = null; - - application.CpuStatsMap.Clear(); - application.LastCpuUsage = 0; + process.Close(); LogAcknowledgeProcessExit(logger, application.Name, exitCode); } private void HandleProcessRestart(object? sender, EventArgs e) { - if (sender is not Process process || !Applications.TryGetValue(process, out var application)) + if (sender is not Process process || !_processToApplicationMap.TryGetValue(process, out var application)) return; var status = _consequentialRestarts.GetValueOrDefault(application.Name, (0, null)); @@ -256,7 +228,9 @@ private void HandleProcessRestart(object? sender, EventArgs e) application.Status = HexusApplicationStatus.Crashed; configManager.SaveConfiguration(); - Applications.TryRemove(process, out _); + _processToApplicationMap.TryRemove(process, out _); + _applicationToProcessMap.TryRemove(application, out _); + return; } @@ -308,7 +282,4 @@ private static TimeSpan CalculateDelay(int restart) => [LoggerMessage(LogLevel.Debug, "Attempting to restart application \"{Name}\", waiting for {Seconds} seconds before restarting")] private static partial void LogRestartAttemptDelay(ILogger logger, string name, double seconds); - - [LoggerMessage(LogLevel.Trace, "Application \"{Name}\" says: '{OutputData}'")] - private static partial void LogApplicationOutput(ILogger logger, string name, string outputData); } diff --git a/Hexus.Daemon/Services/ProcessStatisticsService.cs b/Hexus.Daemon/Services/ProcessStatisticsService.cs new file mode 100644 index 0000000..578be28 --- /dev/null +++ b/Hexus.Daemon/Services/ProcessStatisticsService.cs @@ -0,0 +1,159 @@ +using Hexus.Daemon.Configuration; +using Hexus.Daemon.Extensions; +using Hexus.Daemon.Interop; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Hexus.Daemon.Services; + +internal sealed class ProcessStatisticsService(ProcessManagerService processManagerService) +{ + private readonly ConcurrentDictionary _cpuStatisticsMap = new(); + + public ApplicationStatistics GetApplicationStats(HexusApplication application) + { + if (!processManagerService.IsApplicationRunning(application, out var process) || + !_cpuStatisticsMap.TryGetValue(application, out var cpuStatistics)) + { + return new ApplicationStatistics(TimeSpan.Zero, 0, 0, 0); + } + + return new ApplicationStatistics( + ProcessUptime: DateTime.Now - process.StartTime, + ProcessId: process.Id, + CpuUsage: cpuStatistics.LastUsage, + MemoryUsage: GetMemoryUsage(application) + ); + } + + public void TrackApplicationUsages(HexusApplication application) + { + _cpuStatisticsMap[application] = new ApplicationCpuStatistics(); + } + + public bool StopTrackingApplicationUsage(HexusApplication application) + { + return _cpuStatisticsMap.TryRemove(application, out _); + } + + internal void RefreshCpuUsage() + { + var children = ProcessChildren.GetProcessChildrenInfo(Environment.ProcessId) + .GroupBy(x => x.ParentProcessId) + .ToDictionary(x => x.Key, x => x.Select(inf => inf.ProcessId).ToArray()); + + if (!children.TryGetValue(Environment.ProcessId, out var hexusChildren)) return; + + var liveApplications = _cpuStatisticsMap.Keys + .Select(app => (IsRunning: processManagerService.IsApplicationRunning(app, out var process), Application: app, Process: process)) + .Where(tuple => tuple.IsRunning && hexusChildren.Contains(tuple.Process!.Id)) + .ToDictionary(tuple => tuple.Process!.Id, t => t); + + foreach (var child in hexusChildren) + { + if (!liveApplications.TryGetValue(child, out var tuple)) continue; + + if (!_cpuStatisticsMap.TryGetValue(tuple.Application, out var statistics)) continue; + + var processes = Traverse(child, children).ToArray(); + var cpuUsage = GetApplicationCpuUsage(statistics, processes).Sum(); + statistics.LastUsage = Math.Clamp(Math.Round(cpuUsage, 2), 0, 100); + } + } + + private long GetMemoryUsage(HexusApplication application) + { + if (!processManagerService.IsApplicationRunning(application, out _)) + return 0; + + return GetApplicationProcesses(application) + .Where(proc => proc is { HasExited: false }) + .Select(proc => + { + proc.Refresh(); + + return proc.WorkingSet64; + }) + .Sum(); + } + + #region Refresh CPU Internals + + private static IEnumerable Traverse(int processId, IReadOnlyDictionary processIds) + { + yield return Process.GetProcessById(processId); + + if (!processIds.TryGetValue(processId, out var childrenIds)) yield break; + + foreach (var child in childrenIds) + { + foreach (var childProc in Traverse(child, processIds)) + { + yield return childProc; + } + } + } + + private static IEnumerable GetApplicationCpuUsage(ApplicationCpuStatistics statistics, Process[] processes) + { + var deathChildren = statistics.ProcessCpuStatistics.Keys.Except(processes.Select(x => x.Id)); + + // For death + foreach (var processId in deathChildren) + { + statistics.ProcessCpuStatistics.Remove(processId); + } + + // For newly spawned children and for exiting ones + foreach (var process in processes) + { + var stats = statistics.ProcessCpuStatistics.GetOrCreate(process.Id, _ => new CpuStatistics + { + LastTotalProcessorTime = TimeSpan.Zero, + LastGetProcessCpuUsageInvocation = DateTimeOffset.UtcNow, + }); + + yield return process.GetProcessCpuUsage(stats); + } + } + + #endregion + + private IEnumerable GetApplicationProcesses(HexusApplication application) + { + // If the application has exited we can stop the enumeration + if (!processManagerService.IsApplicationRunning(application, out var process)) + { + yield break; + } + + var children = ProcessChildren.GetProcessChildrenInfo(process.Id); + + yield return process; + + foreach (var child in children) + { + yield return Process.GetProcessById(child.ProcessId); + } + } + + private record ApplicationCpuStatistics + { + public Dictionary ProcessCpuStatistics { get; } = []; + public double LastUsage { get; set; } + } + + internal record CpuStatistics + { + public TimeSpan LastTotalProcessorTime { get; set; } + public DateTimeOffset LastGetProcessCpuUsageInvocation { get; set; } + } +} + + +public record ApplicationStatistics( + TimeSpan ProcessUptime, + long ProcessId, + double CpuUsage, + long MemoryUsage +); diff --git a/Hexus/Commands/Applications/InfoCommand.cs b/Hexus/Commands/Applications/InfoCommand.cs index fbb128c..2fc09cf 100644 --- a/Hexus/Commands/Applications/InfoCommand.cs +++ b/Hexus/Commands/Applications/InfoCommand.cs @@ -49,7 +49,7 @@ private static async Task Handler(InvocationContext context) return; } - var application = await infoRequest.Content.ReadFromJsonAsync(HttpInvocation.JsonSerializerOptions, ct); + var application = await infoRequest.Content.ReadFromJsonAsync(HttpInvocation.JsonSerializerOptions, ct); Debug.Assert(application is not null); diff --git a/Hexus/Commands/Applications/ListCommand.cs b/Hexus/Commands/Applications/ListCommand.cs index fe9b281..68cf7e8 100644 --- a/Hexus/Commands/Applications/ListCommand.cs +++ b/Hexus/Commands/Applications/ListCommand.cs @@ -39,7 +39,7 @@ private static async Task Handler(InvocationContext context) return; } - var applications = await listRequest.Content.ReadFromJsonAsync>(HttpInvocation.JsonSerializerOptions, ct); + var applications = await listRequest.Content.ReadFromJsonAsync>(HttpInvocation.JsonSerializerOptions, ct); Debug.Assert(applications is not null); var table = new Table();