Introduced a `CacheId` field in `HealthCheckHtmlGenerator` to append unique query strings to CSS and JS file references. This ensures updated assets are fetched by bypassing browser cache after application restarts. Removed static asset references in favor of dynamic ones.
338 lines
13 KiB
C#
338 lines
13 KiB
C#
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using System.Text;
|
|
|
|
namespace ECMJobRunner.WebCron.HealthCheck;
|
|
|
|
/// <summary>
|
|
/// Generates HTML representation of ASP.NET Core health check reports.
|
|
/// Produces a Bootstrap 5-based UI with auto-refresh, status indicators, and detailed metrics.
|
|
/// </summary>
|
|
public static class HealthCheckHtmlGenerator
|
|
{
|
|
private static readonly string CacheId = Guid.NewGuid().ToString();
|
|
|
|
/// <summary>
|
|
/// Generates a complete HTML page for displaying health check results.
|
|
/// </summary>
|
|
/// <param name="report">The health check report containing service status information.</param>
|
|
/// <returns>A complete HTML document string ready to be sent as HTTP response.</returns>
|
|
/// <remarks>
|
|
/// The generated UI includes:
|
|
/// <list type="bullet">
|
|
/// <item><description>Navigation links to Hangfire, Serilog.UI, and health endpoints</description></item>
|
|
/// <item><description>Overall health status with color-coded badge</description></item>
|
|
/// <item><description>Summary card with total duration and check count</description></item>
|
|
/// <item><description>Individual check cards with detailed metrics and exception info</description></item>
|
|
/// <item><description>Auto-refresh countdown (10 seconds)</description></item>
|
|
/// </list>
|
|
/// External dependencies:
|
|
/// <list type="bullet">
|
|
/// <item><description>Bootstrap 5.3.0 (CDN)</description></item>
|
|
/// <item><description>/css/health-ui.css (custom styles)</description></item>
|
|
/// <item><description>/js/health-ui.js (auto-refresh logic)</description></item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public static string Generate(HealthReport report)
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine(@$"
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset=""UTF-8"">
|
|
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
|
<title>Health Check - ECMJobRunner</title>
|
|
<link rel=""stylesheet"" href=""https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"" />
|
|
<link rel=""stylesheet"" href=""/css/health-ui.css?{CacheId}"" />
|
|
<script src=""/js/health-ui.js?{CacheId}""></script>
|
|
</head>
|
|
<body>");
|
|
|
|
// Navigation
|
|
AppendNavigation(sb);
|
|
|
|
// Overall Status Header
|
|
AppendOverallStatus(sb, report);
|
|
|
|
// Summary Card
|
|
AppendSummaryCard(sb, report);
|
|
|
|
// Individual Checks
|
|
AppendIndividualChecks(sb, report);
|
|
|
|
// Refresh Info
|
|
AppendRefreshInfo(sb);
|
|
|
|
sb.AppendLine(@"
|
|
</body>
|
|
</html>");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the navigation menu with links to application dashboards.
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
private static void AppendNavigation(StringBuilder sb)
|
|
{
|
|
sb.Append(@"
|
|
<div class=""container-fluid"">
|
|
<div class=""nav-links"">");
|
|
|
|
// Append emojis separately (not in verbatim string)
|
|
sb.AppendLine();
|
|
sb.Append(" <a href=\"/\">🏠 Home</a>");
|
|
sb.AppendLine();
|
|
sb.Append(" <a href=\"/hangfire\">🔧 Hangfire Dashboard</a>");
|
|
sb.AppendLine();
|
|
sb.Append(" <a href=\"/serilog-ui\">📋 Logs (Serilog.UI)</a>");
|
|
sb.AppendLine();
|
|
sb.Append(" <a href=\"/health\">📊 Health API</a>");
|
|
sb.AppendLine();
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the overall status header showing aggregate health and timestamp.
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
/// <param name="report">The health check report.</param>
|
|
private static void AppendOverallStatus(StringBuilder sb, HealthReport report)
|
|
{
|
|
var statusClass = GetStatusClass(report.Status);
|
|
var badgeClass = GetBadgeClass(report.Status);
|
|
var icon = GetStatusIcon(report.Status);
|
|
|
|
sb.Append($@"
|
|
<div class=""header-card"">
|
|
<div class=""d-flex justify-content-between align-items-center"">
|
|
<div>
|
|
<h1 class=""mb-2"">");
|
|
sb.Append(icon);
|
|
sb.Append($@" Health Check Status</h1>
|
|
<p class=""text-muted mb-0"">Last checked: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</p>
|
|
</div>
|
|
<div>
|
|
<h2><span class=""badge {badgeClass} fs-3"">{report.Status}</span></h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class=""row"">
|
|
<div class=""col-md-4"">");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the summary card displaying aggregate metrics (status, duration, check count).
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
/// <param name="report">The health check report.</param>
|
|
private static void AppendSummaryCard(StringBuilder sb, HealthReport report)
|
|
{
|
|
var statusClass = GetStatusClass(report.Status);
|
|
|
|
sb.Append(@"
|
|
<div class=""card"">
|
|
<div class=""card-header bg-primary text-white"">");
|
|
sb.AppendLine();
|
|
sb.Append(" 📊 Summary");
|
|
sb.AppendLine();
|
|
sb.Append($@" </div>
|
|
<div class=""card-body"">
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Overall Status:</span>
|
|
<strong class=""{statusClass}"">{report.Status}</strong>
|
|
</div>
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Total Duration:</span>
|
|
<span class=""metric-value"">{report.TotalDuration.TotalMilliseconds:F0} ms</span>
|
|
</div>
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Checks:</span>
|
|
<span class=""metric-value"">{report.Entries.Count}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class=""col-md-8"">");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends individual health check result cards with detailed metrics.
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
/// <param name="report">The health check report containing check entries.</param>
|
|
private static void AppendIndividualChecks(StringBuilder sb, HealthReport report)
|
|
{
|
|
foreach (var entry in report.Entries)
|
|
{
|
|
var checkStatusClass = GetStatusClass(entry.Value.Status);
|
|
var checkBadgeClass = GetBadgeClass(entry.Value.Status);
|
|
var checkIcon = GetStatusIcon(entry.Value.Status);
|
|
|
|
sb.Append($@"
|
|
<div class=""card"">
|
|
<div class=""card-header bg-light"">
|
|
<div class=""d-flex justify-content-between align-items-center"">
|
|
<span>");
|
|
sb.Append(checkIcon);
|
|
sb.Append($@" <strong>{entry.Key}</strong></span>
|
|
<span class=""badge {checkBadgeClass}"">{entry.Value.Status}</span>
|
|
</div>
|
|
</div>
|
|
<div class=""card-body"">
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Description:</span>
|
|
<span class=""metric-value"">{entry.Value.Description ?? "N/A"}</span>
|
|
</div>
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Duration:</span>
|
|
<span class=""metric-value"">{entry.Value.Duration.TotalMilliseconds:F0} ms</span>
|
|
</div>");
|
|
|
|
if (entry.Value.Exception != null)
|
|
{
|
|
sb.AppendLine($@"
|
|
<div class=""metric"">
|
|
<span class=""metric-label"">Exception:</span>
|
|
<span class=""metric-value text-danger"" style=""word-break: break-all;"">{System.Net.WebUtility.HtmlEncode(entry.Value.Exception.Message)}</span>
|
|
</div>");
|
|
}
|
|
|
|
if (entry.Value.Data.Any())
|
|
{
|
|
AppendCheckDetails(sb, entry.Value.Data);
|
|
}
|
|
|
|
sb.AppendLine(@"
|
|
</div>
|
|
</div>");
|
|
}
|
|
|
|
sb.AppendLine(@"
|
|
</div>
|
|
</div>
|
|
</div>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends a detail table for additional health check data (e.g., timestamps, counters).
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
/// <param name="data">Dictionary of custom data provided by the health check.</param>
|
|
private static void AppendCheckDetails(StringBuilder sb, IReadOnlyDictionary<string, object> data)
|
|
{
|
|
sb.AppendLine(@"
|
|
<div class=""mt-3"">
|
|
<strong class=""metric-label"">Details:</strong>
|
|
<table class=""table table-sm table-borderless mt-2"">");
|
|
|
|
foreach (var item in data)
|
|
{
|
|
var value = FormatDataValue(item.Value);
|
|
|
|
sb.AppendLine($@"
|
|
<tr>
|
|
<td class=""text-muted"" style=""width: 40%;"">{item.Key}</td>
|
|
<td><strong>{value}</strong></td>
|
|
</tr>");
|
|
}
|
|
|
|
sb.AppendLine(@"
|
|
</table>
|
|
</div>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the auto-refresh indicator widget with countdown timer.
|
|
/// </summary>
|
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
|
private static void AppendRefreshInfo(StringBuilder sb)
|
|
{
|
|
sb.AppendLine(@"
|
|
<div class=""refresh-info"">");
|
|
sb.AppendLine();
|
|
sb.Append(" <span class=\"spinner\"></span> Auto-refresh in <strong><span id=\"countdown\">3</span>s</strong>");
|
|
sb.AppendLine();
|
|
sb.AppendLine(@" </div>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a health status to the corresponding CSS class name for styling.
|
|
/// </summary>
|
|
/// <param name="status">The health status.</param>
|
|
/// <returns>CSS class name (e.g., "status-healthy", "status-degraded").</returns>
|
|
private static string GetStatusClass(HealthStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
HealthStatus.Healthy => "status-healthy",
|
|
HealthStatus.Degraded => "status-degraded",
|
|
HealthStatus.Unhealthy => "status-unhealthy",
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a health status to the corresponding Bootstrap badge CSS class.
|
|
/// </summary>
|
|
/// <param name="status">The health status.</param>
|
|
/// <returns>Badge CSS class name (e.g., "badge-healthy", "badge-degraded").</returns>
|
|
private static string GetBadgeClass(HealthStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
HealthStatus.Healthy => "badge-healthy",
|
|
HealthStatus.Degraded => "badge-degraded",
|
|
HealthStatus.Unhealthy => "badge-unhealthy",
|
|
_ => "badge-secondary"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a health status to the corresponding emoji icon.
|
|
/// </summary>
|
|
/// <param name="status">The health status.</param>
|
|
/// <returns>Unicode emoji character (✅ for healthy, ⚠️ for degraded, ❌ for unhealthy).</returns>
|
|
private static string GetStatusIcon(HealthStatus status)
|
|
{
|
|
return status switch
|
|
{
|
|
HealthStatus.Healthy => "✅",
|
|
HealthStatus.Degraded => "⚠️",
|
|
HealthStatus.Unhealthy => "❌",
|
|
_ => "❓"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats health check data values for display.
|
|
/// Applies special formatting for common types (DateTime, TimeSpan).
|
|
/// </summary>
|
|
/// <param name="value">The data value to format.</param>
|
|
/// <returns>
|
|
/// Formatted string representation:
|
|
/// <list type="bullet">
|
|
/// <item><description><see cref="DateTime"/> → "yyyy-MM-dd HH:mm:ss"</description></item>
|
|
/// <item><description><see cref="TimeSpan"/> → "{seconds}s"</description></item>
|
|
/// <item><description>Other types → ToString() or "N/A" if null</description></item>
|
|
/// </list>
|
|
/// </returns>
|
|
private static string FormatDataValue(object? value)
|
|
{
|
|
if (value == null)
|
|
return "N/A";
|
|
|
|
if (value is DateTime dt)
|
|
return dt.ToString("yyyy-MM-dd HH:mm:ss");
|
|
|
|
if (value is TimeSpan ts)
|
|
return $"{ts.TotalSeconds:F1}s";
|
|
|
|
return value.ToString() ?? "N/A";
|
|
}
|
|
}
|