feat(api): expose /health endpoint and health dashboard

Wire the infrastructure health checks into ASP.NET Core:

- /health returns JSON via UIResponseWriter and maps Unhealthy to HTTP 503
- /health-ui renders a Razor Pages dashboard with per-check status, description, tags and duration
- Tag health checks (rabbitmq, workers, ready, live) for monitoring filters
- Link the dashboard from the Swagger description and the page layout
This commit is contained in:
2026-11-02 10:11:14 +01:00
parent 2fdc8982aa
commit 292b6e9f36
6 changed files with 164 additions and 2 deletions

View File

@@ -26,8 +26,9 @@
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="8.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,68 @@
@page "/health-ui"
@model DigitalData.MessagingService.API.Pages.HealthDashboardModel
@{
ViewData["Title"] = "Health Dashboard";
Layout = "_Layout";
}
<style>
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #e2e8f0; }
.overall { display: inline-flex; align-items: center; gap: .5rem; font-size: 1rem; font-weight: 600;
padding: .35rem .9rem; border-radius: 999px; margin-bottom: 2rem; }
.overall.Healthy { background: #14532d; color: #4ade80; }
.overall.Degraded { background: #713f12; color: #fbbf24; }
.overall.Unhealthy { background: #7f1d1d; color: #f87171; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.card { background: #1a1d2e; border: 1px solid #2d3148; border-radius: .75rem; padding: 1.25rem; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
.card-name { font-weight: 600; font-size: .95rem; }
.badge { font-size: .75rem; font-weight: 700; padding: .2rem .6rem; border-radius: 999px; }
.badge-Healthy { background: #14532d; color: #4ade80; }
.badge-Degraded { background: #713f12; color: #fbbf24; }
.badge-Unhealthy { background: #7f1d1d; color: #f87171; }
.desc { font-size: .82rem; color: #94a3b8; }
.tags { margin-top: .6rem; display: flex; gap: .4rem; flex-wrap: wrap; }
.tag { font-size: .7rem; background: #2d3148; color: #94a3b8; padding: .15rem .5rem; border-radius: 4px; }
.duration { font-size: .75rem; color: #64748b; margin-top: .5rem; }
.refresh { font-size: .8rem; color: #64748b; margin-bottom: 1.5rem; }
.refresh a { color: #a78bfa; text-decoration: none; }
</style>
<h1>Health Dashboard</h1>
<div class="overall @Model.OverallStatus.ToString()">
<span>@(Model.OverallStatus == Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy ? "?" : "?")</span>
<span>@Model.OverallStatus</span>
</div>
<p class="refresh">
Zuletzt geprüft: <strong>@Model.CheckedAt.ToString("yyyy-MM-dd HH:mm:ss")</strong> UTC
&nbsp;·&nbsp; <a href="/health-ui">Aktualisieren</a>
&nbsp;·&nbsp; <a href="/health" target="_blank">JSON</a>
</p>
<div class="grid">
@foreach (var entry in Model.Entries)
{
<div class="card">
<div class="card-header">
<span class="card-name">@entry.Name</span>
<span class="badge badge-@entry.Status.ToString()">@entry.Status</span>
</div>
@if (!string.IsNullOrWhiteSpace(entry.Description))
{
<div class="desc">@entry.Description</div>
}
@if (entry.Tags.Any())
{
<div class="tags">
@foreach (var tag in entry.Tags)
{
<span class="tag">@tag</span>
}
</div>
}
<div class="duration">Dauer: @entry.Duration.TotalMilliseconds.ToString("0")ms</div>
</div>
}
</div>

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace DigitalData.MessagingService.API.Pages;
public class HealthDashboardModel(HealthCheckService healthCheckService) : PageModel
{
public HealthStatus OverallStatus { get; private set; }
public DateTime CheckedAt { get; private set; }
public IReadOnlyList<HealthEntry> Entries { get; private set; } = [];
public async Task OnGetAsync()
{
var report = await healthCheckService.CheckHealthAsync();
CheckedAt = DateTime.UtcNow;
OverallStatus = report.Status;
Entries = report.Entries
.Select(e => new HealthEntry(
Name: e.Key,
Status: e.Value.Status,
Description: e.Value.Description,
Duration: e.Value.Duration,
Tags: e.Value.Tags))
.OrderBy(e => e.Status)
.ToList();
}
public record HealthEntry(
string Name,
HealthStatus Status,
string? Description,
TimeSpan Duration,
IEnumerable<string> Tags);
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] — DigitalData MessagingService</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f1117; color: #e2e8f0; }
header { background: #1a1d2e; border-bottom: 1px solid #2d3148; padding: 0 2rem; display: flex; align-items: center; height: 56px; gap: 1.5rem; }
header a { color: #a78bfa; text-decoration: none; font-weight: 600; font-size: 1rem; }
header a:hover { color: #c4b5fd; }
header .brand { color: #e2e8f0; font-size: 1.1rem; font-weight: 700; margin-right: auto; }
main { padding: 2rem; max-width: 1100px; margin: 0 auto; }
</style>
</head>
<body>
<header>
<span class="brand">DigitalData MessagingService</span>
<a href="/health-ui">Health</a>
<a href="/serilog-ui">Logs</a>
<a href="/swagger">API</a>
</header>
<main>
@RenderBody()
</main>
</body>
</html>

View File

@@ -0,0 +1,3 @@
@using DigitalData.MessagingService.API.Pages
@namespace DigitalData.MessagingService.API.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -1,6 +1,10 @@
using DigitalData.MessagingService.API.Middleware;
using DigitalData.MessagingService.Application;
using DigitalData.MessagingService.Infrastructure;
using DigitalData.MessagingService.Infrastructure.Services.Background;
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
@@ -66,7 +70,14 @@ try
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
builder.Services.AddInfrastructure(builder.Configuration);
// Health checks — each check corresponds to a singleton registered in the Infrastructure layer
builder.Services.AddHealthChecks()
.AddCheck<ConsumerInitHealthCheck>("rabbitmq-consumers", tags: ["rabbitmq", "ready"])
.AddCheck<RabbitMqConnectionHealthCheck>("rabbitmq-connection", tags: ["rabbitmq", "live"])
.AddCheck<EmailSyncWorkerHealthCheck>("email-sync-worker", tags: ["workers", "live"]);
builder.Services.AddControllers();
builder.Services.AddRazorPages();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
@@ -95,6 +106,7 @@ try
## Weiterführende Links
- [Serilog Log-Viewer &nearr;](/serilog-ui)
- [Health Status &nearr;](/health-ui)
"""
});
});
@@ -155,6 +167,22 @@ try
app.MapControllers();
// Health check endpoint — raw JSON, used by HealthChecks UI and monitoring tools
// Returns HTTP 503 if any check tagged 'ready' is Unhealthy
app.MapHealthChecks("/health", new HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status200OK,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
}
});
app.MapRazorPages();
Log.Information("MessagingService API started successfully");
app.Run();