diff --git a/Moonlight/App/Diagnostics/HealthChecks/DaemonHealthCheck.cs b/Moonlight/App/Diagnostics/HealthChecks/DaemonHealthCheck.cs new file mode 100644 index 00000000..ce024112 --- /dev/null +++ b/Moonlight/App/Diagnostics/HealthChecks/DaemonHealthCheck.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moonlight.App.Database.Entities; +using Moonlight.App.Repositories; +using Moonlight.App.Services; + +namespace Moonlight.App.Diagnostics.HealthChecks; + +public class DaemonHealthCheck : IHealthCheck +{ + private readonly Repository NodeRepository; + private readonly NodeService NodeService; + + public DaemonHealthCheck(Repository nodeRepository, NodeService nodeService) + { + NodeRepository = nodeRepository; + NodeService = nodeService; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) + { + var nodes = NodeRepository.Get().ToArray(); + + var results = new Dictionary(); + var healthCheckData = new Dictionary(); + + foreach (var node in nodes) + { + try + { + await NodeService.GetCpuMetrics(node); + + results.Add(node, true); + } + catch (Exception e) + { + results.Add(node, false); + healthCheckData.Add(node.Name, e.ToStringDemystified()); + } + } + + var offlineNodes = results + .Where(x => !x.Value) + .ToArray(); + + if (offlineNodes.Length == nodes.Length) + { + return HealthCheckResult.Unhealthy("All node daemons are offline", null, healthCheckData); + } + + if (offlineNodes.Length == 0) + { + return HealthCheckResult.Healthy("All node daemons are online"); + } + + return HealthCheckResult.Degraded($"{offlineNodes.Length} node daemons are offline", null, healthCheckData); + } +} \ No newline at end of file diff --git a/Moonlight/App/Diagnostics/HealthChecks/DatabaseHealthCheck.cs b/Moonlight/App/Diagnostics/HealthChecks/DatabaseHealthCheck.cs new file mode 100644 index 00000000..34c23b5b --- /dev/null +++ b/Moonlight/App/Diagnostics/HealthChecks/DatabaseHealthCheck.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moonlight.App.Database; + +namespace Moonlight.App.Diagnostics.HealthChecks; + +public class DatabaseHealthCheck : IHealthCheck +{ + private readonly DataContext DataContext; + + public DatabaseHealthCheck(DataContext dataContext) + { + DataContext = dataContext; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = new CancellationToken()) + { + try + { + await DataContext.Database.OpenConnectionAsync(cancellationToken); + await DataContext.Database.CloseConnectionAsync(); + + return HealthCheckResult.Healthy("Database is online"); + } + catch (Exception e) + { + return HealthCheckResult.Unhealthy("Database is offline", e); + } + } +} \ No newline at end of file diff --git a/Moonlight/App/Diagnostics/HealthChecks/NodeHealthCheck.cs b/Moonlight/App/Diagnostics/HealthChecks/NodeHealthCheck.cs new file mode 100644 index 00000000..b330ffcc --- /dev/null +++ b/Moonlight/App/Diagnostics/HealthChecks/NodeHealthCheck.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moonlight.App.Database.Entities; +using Moonlight.App.Repositories; +using Moonlight.App.Services; + +namespace Moonlight.App.Diagnostics.HealthChecks; + +public class NodeHealthCheck : IHealthCheck +{ + private readonly Repository NodeRepository; + private readonly NodeService NodeService; + + public NodeHealthCheck(Repository nodeRepository, NodeService nodeService) + { + NodeRepository = nodeRepository; + NodeService = nodeService; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) + { + var nodes = NodeRepository.Get().ToArray(); + + var results = new Dictionary(); + var healthCheckData = new Dictionary(); + + foreach (var node in nodes) + { + try + { + await NodeService.GetStatus(node); + + results.Add(node, true); + } + catch (Exception e) + { + results.Add(node, false); + healthCheckData.Add(node.Name, e.ToStringDemystified()); + } + } + + var offlineNodes = results + .Where(x => !x.Value) + .ToArray(); + + if (offlineNodes.Length == nodes.Length) + { + return HealthCheckResult.Unhealthy("All nodes are offline", null, healthCheckData); + } + + if (offlineNodes.Length == 0) + { + return HealthCheckResult.Healthy("All nodes are online"); + } + + return HealthCheckResult.Degraded($"{offlineNodes.Length} nodes are offline", null, healthCheckData); + } +} \ No newline at end of file diff --git a/Moonlight/App/Models/Misc/HealthCheck.cs b/Moonlight/App/Models/Misc/HealthCheck.cs new file mode 100644 index 00000000..c7df29b7 --- /dev/null +++ b/Moonlight/App/Models/Misc/HealthCheck.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.Models.Misc; + +public class HealthCheck +{ + public string Status { get; set; } + public TimeSpan TotalDuration { get; set; } + public Dictionary Entries { get; set; } = new(); + + public class HealthCheckEntry + { + public Dictionary Data { get; set; } = new(); + public string Description { get; set; } + public TimeSpan Duration { get; set; } + public string Status { get; set; } + public List Tags { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index 7531db6e..85f2a7f0 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -9,6 +9,7 @@ + diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index a3795997..ab7aee4d 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -1,12 +1,14 @@ using BlazorDownloadFile; using BlazorTable; using CurrieTechnologies.Razor.SweetAlert2; +using HealthChecks.UI.Client; using Logging.Net; using Moonlight.App.ApiClients.CloudPanel; using Moonlight.App.ApiClients.Daemon; using Moonlight.App.ApiClients.Paper; using Moonlight.App.ApiClients.Wings; using Moonlight.App.Database; +using Moonlight.App.Diagnostics.HealthChecks; using Moonlight.App.Events; using Moonlight.App.Helpers; using Moonlight.App.Helpers.Wings; @@ -66,6 +68,10 @@ namespace Moonlight options.HandshakeTimeout = TimeSpan.FromSeconds(10); }); builder.Services.AddHttpContextAccessor(); + builder.Services.AddHealthChecks() + .AddCheck("Database") + .AddCheck("Nodes") + .AddCheck("Daemons"); // Databases builder.Services.AddDbContext(); @@ -186,6 +192,10 @@ namespace Moonlight app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); + app.MapHealthChecks("/_health", new() + { + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }); // AutoStart services _ = app.Services.GetRequiredService(); diff --git a/Moonlight/Shared/Components/Partials/HealthCheckView.razor b/Moonlight/Shared/Components/Partials/HealthCheckView.razor new file mode 100644 index 00000000..3e6870b5 --- /dev/null +++ b/Moonlight/Shared/Components/Partials/HealthCheckView.razor @@ -0,0 +1,59 @@ +@using Moonlight.App.Models.Misc +@using System.Text +@using Moonlight.App.Helpers + +@{ + string GetStatusColor(string s) + { + if (s == "Healthy") + return "success"; + else if (s == "Unhealthy") + return "danger"; + else + return "warning"; + } +} + +
+
+
+ Moonlight health: +
+ @(HealthCheck.Status) +
+
+
+
+
+ @foreach (var entry in HealthCheck.Entries) + { +
+

+ +

+
+
+ Status: @(entry.Value.Status)
+ Description: @(entry.Value.Description)
+
+ @foreach (var x in entry.Value.Data) + { + @(x.Key) +
+ @(x.Value)
+ } +
+
+
+ } +
+
+
+ +@code +{ + [Parameter] + public HealthCheck HealthCheck { get; set; } +} \ No newline at end of file diff --git a/Moonlight/Shared/Views/Admin/Index.razor b/Moonlight/Shared/Views/Admin/Index.razor index b340e241..f8f18b3a 100644 --- a/Moonlight/Shared/Views/Admin/Index.razor +++ b/Moonlight/Shared/Views/Admin/Index.razor @@ -3,11 +3,16 @@ @using Moonlight.App.Repositories @using Moonlight.App.Repositories.Domains @using Moonlight.App.Database.Entities +@using Moonlight.App.Models.Misc +@using Moonlight.App.Services +@using Newtonsoft.Json +@using Logging.Net @inject ServerRepository ServerRepository @inject UserRepository UserRepository @inject Repository WebSpaceRepository @inject DomainRepository DomainRepository +@inject ConfigService ConfigService @@ -97,6 +102,28 @@ + + + @if (HealthCheckData == null) + { +
+
+
+ Moonlight health +
+
+
+
+ Unable to fetch health check data +
+
+
+ } + else + { + + } +
@@ -107,6 +134,8 @@ private int DomainCount = 0; private int WebSpaceCount = 0; + private HealthCheck? HealthCheckData; + private Task Load(LazyLoader lazyLoader) { ServerCount = ServerRepository.Get().Count(); @@ -116,4 +145,26 @@ return Task.CompletedTask; } + + private async Task LoadHealthCheckData(LazyLoader lazyLoader) + { + await lazyLoader.SetText("Loading health check data"); + + var appUrl = ConfigService + .GetSection("Moonlight") + .GetValue("AppUrl"); + + try + { + using var client = new HttpClient(); + var json = await client.GetStringAsync($"{appUrl}/_health"); + HealthCheckData = JsonConvert.DeserializeObject(json) ?? new(); + } + catch (Exception e) + { + HealthCheckData = null; + Logger.Warn("Unable to fetch health check data"); + Logger.Warn(e); + } + } } \ No newline at end of file