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 d519c643..0de0ce70 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..9d9e7950 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,9 @@ namespace Moonlight options.HandshakeTimeout = TimeSpan.FromSeconds(10); }); builder.Services.AddHttpContextAccessor(); + builder.Services.AddHealthChecks() + .AddCheck("Database") + .AddCheck("Nodes"); // Databases builder.Services.AddDbContext(); @@ -186,6 +191,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..c3cfb050 --- /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..02308e44 100644 --- a/Moonlight/Shared/Views/Admin/Index.razor +++ b/Moonlight/Shared/Views/Admin/Index.razor @@ -3,11 +3,15 @@ @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 @inject ServerRepository ServerRepository @inject UserRepository UserRepository @inject Repository WebSpaceRepository @inject DomainRepository DomainRepository +@inject ConfigService ConfigService @@ -97,6 +101,7 @@ + @@ -107,13 +112,33 @@ private int DomainCount = 0; private int WebSpaceCount = 0; - private Task Load(LazyLoader lazyLoader) + private HealthCheck HealthCheckData; + + private async Task Load(LazyLoader lazyLoader) { ServerCount = ServerRepository.Get().Count(); UserCount = UserRepository.Get().Count(); DomainCount = DomainRepository.Get().Count(); WebSpaceCount = WebSpaceRepository.Get().Count(); - return Task.CompletedTask; + 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) + { + HealthCheckData = new() + { + Status = "Healthy", + Entries = new(), + TotalDuration = TimeSpan.MinValue + }; + } } } \ No newline at end of file