diff --git a/JenkinsFile b/JenkinsFile index 9c7155d6..95959201 100644 --- a/JenkinsFile +++ b/JenkinsFile @@ -109,6 +109,27 @@ pipeline { networkShares() filecopy copyOfflineOperations, 'test', env.WORKSPACE filecopy copyOperations, 'test', env.WORKSPACE + powershell label: 'Verify /health returns 200', script: ''' + $url = "https://secure-test.vetmed.ucdavis.edu/2/health" + $maxAttempts = 15 + for ($i = 1; $i -le $maxAttempts; $i++) { + # 2s for the first few attempts then 4s - absorbs IIS app-pool warm-up + $delay = if ($i -le 5) { 2 } else { 4 } + try { + $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10 + if ($response.StatusCode -eq 200) { + Write-Host "Attempt ${i}: $url returned 200 OK" + exit 0 + } + Write-Host "Attempt ${i}: $url returned $($response.StatusCode)" + } catch { + Write-Host "Attempt ${i}: $($_.Exception.Message)" + } + if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delay } + } + Write-Error "Health check at $url failed after $maxAttempts attempts" + exit 1 + ''' } } stage('Deploy to prod') { @@ -122,6 +143,26 @@ pipeline { networkShares() filecopy copyOfflineOperations, 'prod', env.WORKSPACE filecopy copyOperations, 'prod', env.WORKSPACE + powershell label: 'Verify /health returns 200', script: ''' + $url = "https://secure.vetmed.ucdavis.edu/2/health" + $maxAttempts = 15 + for ($i = 1; $i -le $maxAttempts; $i++) { + $delay = if ($i -le 5) { 2 } else { 4 } + try { + $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10 + if ($response.StatusCode -eq 200) { + Write-Host "Attempt ${i}: $url returned 200 OK" + exit 0 + } + Write-Host "Attempt ${i}: $url returned $($response.StatusCode)" + } catch { + Write-Host "Attempt ${i}: $($_.Exception.Message)" + } + if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delay } + } + Write-Error "Health check at $url failed after $maxAttempts attempts" + exit 1 + ''' } } } diff --git a/web/Classes/CloudflareNetworks.cs b/web/Classes/CloudflareNetworks.cs new file mode 100644 index 00000000..983ca707 --- /dev/null +++ b/web/Classes/CloudflareNetworks.cs @@ -0,0 +1,59 @@ +namespace Viper.Classes +{ + /// + /// Cloudflare's published IPv4/IPv6 networks, used to mark CF as a known + /// proxy in ForwardedHeadersOptions. Fetched from cloudflare.com at startup + /// so we automatically pick up rotations; falls back to a hardcoded snapshot + /// when the fetch fails (CF outage during deploy, sandboxed network, etc). + /// + public static class CloudflareNetworks + { + // Snapshot of https://www.cloudflare.com/ips/ - only used when the + // runtime fetch fails. Refresh occasionally if logs show this falling + // through and current CF IPs aren't in the list. + private static readonly string[] HardcodedFallback = + [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ]; + + public static IReadOnlyList FetchOrFallback(NLog.Logger logger) + { + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var v4 = http.GetStringAsync("https://www.cloudflare.com/ips-v4/").GetAwaiter().GetResult(); + var v6 = http.GetStringAsync("https://www.cloudflare.com/ips-v6/").GetAwaiter().GetResult(); + var cidrs = (v4 + "\n" + v6) + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + logger.Info("Fetched {Count} Cloudflare networks from cloudflare.com", cidrs.Length); + return cidrs; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + logger.Warn(ex, "Failed to fetch Cloudflare IP ranges; using hardcoded fallback ({Count} entries)", HardcodedFallback.Length); + return HardcodedFallback; + } + } + } +} diff --git a/web/Classes/HealthChecks/AdaptivePollingHealthCheck.cs b/web/Classes/HealthChecks/AdaptivePollingHealthCheck.cs new file mode 100644 index 00000000..d44b5cdb --- /dev/null +++ b/web/Classes/HealthChecks/AdaptivePollingHealthCheck.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Throttles an inner health check by caching its last result for a + /// status-dependent duration: Healthy results are reused for longer, while + /// Unhealthy/Degraded results refresh on a tighter cycle so recovery is + /// noticed quickly. When a cached result is returned, the original probe + /// timestamp is appended to the description so operators can tell how + /// stale the reading is. + /// + public class AdaptivePollingHealthCheck : IHealthCheck + { + private readonly IHealthCheck _inner; + private readonly TimeSpan _healthyCacheDuration; + private readonly TimeSpan _unhealthyCacheDuration; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private DateTime _lastCheckTime; + private HealthCheckResult? _lastResult; + + public AdaptivePollingHealthCheck( + IHealthCheck inner, + TimeSpan healthyCacheDuration, + TimeSpan unhealthyCacheDuration) + { + _inner = inner; + _healthyCacheDuration = healthyCacheDuration; + _unhealthyCacheDuration = unhealthyCacheDuration; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + try + { + if (_lastResult.HasValue) + { + // S6561: DateTime.Now used for elapsed-time calc. Accepted + // because VIPER convention is DateTimeKind.Local and a + // sub-hour DST skew only shifts one cache window. +#pragma warning disable S6561 + var elapsed = DateTime.Now - _lastCheckTime; +#pragma warning restore S6561 + var cacheDuration = _lastResult.Value.Status == HealthStatus.Healthy + ? _healthyCacheDuration + : _unhealthyCacheDuration; + + if (elapsed < cacheDuration) + { + return AppendTimestamp(_lastResult.Value, _lastCheckTime); + } + } + + var result = await _inner.CheckHealthAsync(context, cancellationToken); + _lastResult = result; + _lastCheckTime = DateTime.Now; + return result; + } + finally + { + _semaphore.Release(); + } + } + + private static HealthCheckResult AppendTimestamp(HealthCheckResult result, DateTime lastCheckedAt) + { + var stamp = $"Last checked: {lastCheckedAt:MMM d, h:mm tt}"; + var description = string.IsNullOrWhiteSpace(result.Description) + ? stamp + : $"{result.Description}\n{stamp}"; + return new HealthCheckResult( + result.Status, + description, + result.Exception, + result.Data); + } + } +} diff --git a/web/Classes/HealthChecks/AwsSsmHealthCheck.cs b/web/Classes/HealthChecks/AwsSsmHealthCheck.cs new file mode 100644 index 00000000..444aca59 --- /dev/null +++ b/web/Classes/HealthChecks/AwsSsmHealthCheck.cs @@ -0,0 +1,56 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.SimpleSystemsManagement; +using Amazon.SimpleSystemsManagement.Model; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Verifies AWS SSM Parameter Store is reachable with the app's credentials. + /// Uses a lightweight DescribeParameters probe (MaxResults=1) so the check + /// does not actually fetch any parameter values. + /// + public class AwsSsmHealthCheck : IHealthCheck + { + private readonly RegionEndpoint _region; + private readonly bool _healthyWhenMissing; + + /// + /// If true, missing credentials or client-side SDK errors return Healthy + /// with a "skipped" description. Use for Development where local machines + /// may not have AWS credentials configured. + /// + public AwsSsmHealthCheck(RegionEndpoint? region = null, bool healthyWhenMissing = false) + { + _region = region ?? RegionEndpoint.USWest1; + _healthyWhenMissing = healthyWhenMissing; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + using var client = new AmazonSimpleSystemsManagementClient(_region); + await client.DescribeParametersAsync( + new DescribeParametersRequest { MaxResults = 1 }, + cancellationToken); + return HealthCheckResult.Healthy("AWS SSM reachable."); + } + catch (AmazonServiceException ex) + { + return _healthyWhenMissing + ? HealthCheckResult.Healthy("AWS SSM not configured (skipped).") + : HealthCheckResult.Unhealthy($"AWS SSM unreachable: {ex.ErrorCode}."); + } + catch (AmazonClientException) + { + return _healthyWhenMissing + ? HealthCheckResult.Healthy("AWS SSM not configured (skipped).") + : HealthCheckResult.Unhealthy("AWS SSM client error (credentials or network)."); + } + } + } +} diff --git a/web/Classes/HealthChecks/DiskSpaceHealthCheck.cs b/web/Classes/HealthChecks/DiskSpaceHealthCheck.cs new file mode 100644 index 00000000..f854949b --- /dev/null +++ b/web/Classes/HealthChecks/DiskSpaceHealthCheck.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Reports free space on the drive hosting the running application. + /// Resolves the drive at runtime so the same check works on any deploy target + /// without per-environment config. Thresholds are percent-based so a single + /// default works across drive sizes (a 1 GB floor is alarming on a 20 GB + /// drive and meaningless on a 2 TB drive). + /// + public class DiskSpaceHealthCheck : IHealthCheck + { + private readonly string? _explicitDrivePath; + private readonly double _criticalFreePercent; + private readonly double _warningFreePercent; + private readonly bool _healthyWhenMissing; + private readonly bool _requirePathExists; + private readonly bool _verifyWritable; + + /// + /// Drive or path to monitor. If null, the drive hosting the running app is used. + /// Pass e.g. "S:\\" (or any path on that drive) to monitor an alternate volume. + /// + /// + /// If true, a missing or unready drive returns Healthy with a "not mounted" + /// description. Use for optional drives (e.g., network shares that don't + /// exist on developer machines). Defaults to false (missing drive = Unhealthy). + /// + /// + /// If true, also verify the supplied explicitDrivePath is an existing directory + /// (not just that its drive is ready). Use for checks where the application + /// writes to a specific sub-path and its absence is a real failure. Ignored + /// when explicitDrivePath is null. + /// + /// + /// If true, attempt a zero-byte file create + delete in the target directory + /// to confirm the path is actually writable (catches read-only mounts and + /// ACL regressions that a disk-space check misses). Windows has no reliable + /// "can I write?" API short of actually writing, so this is the minimal probe. + /// Requires explicitDrivePath to be set and point at a directory. + /// + public DiskSpaceHealthCheck( + string? explicitDrivePath = null, + double criticalFreePercent = 5.0, + double warningFreePercent = 10.0, + bool healthyWhenMissing = false, + bool requirePathExists = false, + bool verifyWritable = false) + { + _explicitDrivePath = explicitDrivePath; + _criticalFreePercent = criticalFreePercent; + _warningFreePercent = warningFreePercent; + _healthyWhenMissing = healthyWhenMissing; + _requirePathExists = requirePathExists; + _verifyWritable = verifyWritable; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var driveRoot = _explicitDrivePath is null + ? Path.GetPathRoot(AppContext.BaseDirectory) + : Path.GetPathRoot(_explicitDrivePath); + if (string.IsNullOrEmpty(driveRoot)) + { + return Task.FromResult(_healthyWhenMissing + ? HealthCheckResult.Healthy("Drive not mounted (skipped).") + : HealthCheckResult.Unhealthy("Could not determine drive to monitor.")); + } + + var drive = new DriveInfo(driveRoot); + if (!drive.IsReady) + { + return Task.FromResult(_healthyWhenMissing + ? HealthCheckResult.Healthy("Drive not mounted (skipped).") + : HealthCheckResult.Unhealthy("Drive not ready.")); + } + + if (_requirePathExists && _explicitDrivePath is not null + && !Directory.Exists(_explicitDrivePath)) + { + return Task.FromResult(_healthyWhenMissing + ? HealthCheckResult.Healthy($"Path '{_explicitDrivePath}' does not exist (skipped).") + : HealthCheckResult.Unhealthy($"Path '{_explicitDrivePath}' does not exist.")); + } + + if (_verifyWritable && _explicitDrivePath is not null) + { + // Unique probe name per invocation - overlapping UI polls + /health/detail + // requests would otherwise race on a shared file name and produce + // intermittent false Unhealthy results. + var probePath = Path.Join( + _explicitDrivePath, + $".health-probe-{Environment.ProcessId}-{Guid.NewGuid():N}"); + try + { + File.WriteAllBytes(probePath, Array.Empty()); + } + catch (UnauthorizedAccessException) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + $"Path '{_explicitDrivePath}' not writable: access denied.")); + } + catch (IOException ex) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + $"Path '{_explicitDrivePath}' not writable: {ex.Message}")); + } + finally + { + if (File.Exists(probePath)) + { + try + { + File.Delete(probePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup; unique name means a missed delete + // doesn't break future probes, just leaves a 0-byte file. + } + } + } + } + + var freeBytes = drive.AvailableFreeSpace; + var totalBytes = drive.TotalSize; + var freeGb = Math.Round(freeBytes / (1024.0 * 1024.0 * 1024.0), 1); + var freePercent = Math.Round(freeBytes * 100.0 / totalBytes, 1); + var data = new Dictionary + { + ["drive"] = drive.Name, + ["free_gb"] = freeGb, + ["total_gb"] = Math.Round(totalBytes / (1024.0 * 1024.0 * 1024.0), 1), + ["free_percent"] = freePercent, + }; + + if (freePercent < _criticalFreePercent) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + $"Low disk space: {freeGb} GB free ({freePercent}%).", data: data)); + } + + if (freePercent < _warningFreePercent) + { + return Task.FromResult(HealthCheckResult.Degraded( + $"Disk space getting low: {freeGb} GB free ({freePercent}%).", data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy( + $"Disk space OK: {freeGb} GB free ({freePercent}%).", data: data)); + } + } +} diff --git a/web/Classes/HealthChecks/HealthCheckCollectorAuth.cs b/web/Classes/HealthChecks/HealthCheckCollectorAuth.cs new file mode 100644 index 00000000..b05852ea --- /dev/null +++ b/web/Classes/HealthChecks/HealthCheckCollectorAuth.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Process-unique secret used by the in-app HealthChecksUI collector to bypass + /// the InternalAllowlist IP check on its self-call to /health/detail. The token + /// is regenerated each time the app starts; both the outbound handler and the + /// inbound filter live in the same process, so they always agree. + /// + public static class HealthCheckCollectorAuth + { + public const string HeaderName = "X-Health-Collector-Token"; + + public static string Token { get; } = Guid.NewGuid().ToString("N"); + + /// + /// Constant-time comparison so we don't leak token bytes via timing. + /// + public static bool Matches(string? provided) + { + if (string.IsNullOrEmpty(provided)) return false; + var a = Encoding.UTF8.GetBytes(provided); + var b = Encoding.UTF8.GetBytes(Token); + return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b); + } + } +} diff --git a/web/Classes/HealthChecks/HealthCheckCollectorTokenHandler.cs b/web/Classes/HealthChecks/HealthCheckCollectorTokenHandler.cs new file mode 100644 index 00000000..34c49c52 --- /dev/null +++ b/web/Classes/HealthChecks/HealthCheckCollectorTokenHandler.cs @@ -0,0 +1,19 @@ +namespace Viper.Classes.HealthChecks +{ + /// + /// Wired into HealthChecksUI's API-endpoint HttpClient via + /// UseApiEndpointDelegatingHandler. Stamps every outbound collector request + /// with the process-unique token so the /health/detail endpoint filter can + /// distinguish "us calling ourselves" from arbitrary remote callers. + /// + public sealed class HealthCheckCollectorTokenHandler : DelegatingHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Remove(HealthCheckCollectorAuth.HeaderName); + request.Headers.Add(HealthCheckCollectorAuth.HeaderName, HealthCheckCollectorAuth.Token); + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/web/Classes/HealthChecks/HealthCheckExtensions.cs b/web/Classes/HealthChecks/HealthCheckExtensions.cs new file mode 100644 index 00000000..3970232c --- /dev/null +++ b/web/Classes/HealthChecks/HealthCheckExtensions.cs @@ -0,0 +1,340 @@ +using System.Text; +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Viper.Classes.SQLContext; +using Web.Authorization; + +namespace Viper.Classes.HealthChecks +{ + /// + /// DI + pipeline wiring for /health, /health/detail, and /healthchecks. + /// Kept here so Program.cs shows two lines for this concern instead of ~80. + /// + public static class HealthCheckExtensions + { + // Per-process cache-buster for the injected UI-extras script, so browsers + // re-fetch after a deploy without requiring a hard refresh. + private static readonly string _assetVersion = DateTime.Now.Ticks.ToString(); + + /// + /// Path prefixes owned by the HealthChecks.UI dashboard and its assets. + /// Program.cs uses this list both to skip CSP (the UI bundle relies on + /// inline scripts / data: fonts) and to IP-gate every sub-path. + /// + public static readonly string[] UIPaths = + [ + "/healthchecks", + "/healthchecks-api", + "/ui/resources", + ]; + + /// True if the request targets any health-UI path. + public static bool IsUIPath(PathString path) => + UIPaths.Any(prefix => path.StartsWithSegments(prefix)); + + /// + /// Registers all health checks plus HealthChecks.UI. Checks tagged "ready" + /// run on /health/detail; /health is bare liveness. + /// + public static IServiceCollection AddViperHealthChecks( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment) + { + // Names use a "group-qualifier" convention ("db-*", "disk-space-*") so + // the UI's alphabetical sort groups related checks visually. + var builder = services.AddHealthChecks() + .AddDbContextCheck("db-aaud", tags: new[] { "ready" }) + .AddDbContextCheck("db-clinical-scheduler", tags: new[] { "ready" }) + .AddDbContextCheck("db-courses", tags: new[] { "ready" }) + .AddDbContextCheck("db-crest", tags: new[] { "ready" }) + .AddDbContextCheck("db-dictionary", tags: new[] { "ready" }) + .AddDbContextCheck("db-eval-harvest", tags: new[] { "ready" }) + .AddDbContextCheck("db-raps", tags: new[] { "ready" }) + .AddDbContextCheck("db-sis", tags: new[] { "ready" }) + .AddDbContextCheck("db-viper", tags: new[] { "ready" }) + .AddCheck("disk-space-app", new DiskSpaceHealthCheck(), tags: new[] { "ready" }); + + // Photo gallery drive. Always registered so operators can see the check + // exists; in Development the drive is a network share not mounted locally, + // so healthyWhenMissing=true treats "drive absent" as a pass (with a + // "skipped" description) rather than a permanent Unhealthy in dev. + var photoPath = configuration["PhotoGallery:IDCardPhotoPath"]; + if (!string.IsNullOrWhiteSpace(photoPath)) + { + builder.AddCheck( + "disk-space-photos", + new DiskSpaceHealthCheck( + explicitDrivePath: photoPath, + healthyWhenMissing: environment.IsDevelopment()), + tags: new[] { "ready" }); + } + + // CMS files drive. Same pattern as photos - the drive (S:\) is a network + // share unmounted on developer machines, so skip in dev. Path mirrors + // Areas/CMS/Data/CMS.GetRootFileFolder(). + var cmsFilesPath = environment.IsDevelopment() ? @"C:\Sites\Files" : @"S:\Files"; + builder.AddCheck( + "disk-space-cms", + new DiskSpaceHealthCheck( + explicitDrivePath: cmsFilesPath, + healthyWhenMissing: environment.IsDevelopment()), + tags: new[] { "ready" }); + + // NLog writes to LoggingPath (C:\nlog in dev, S:\nlog in test/prod). + // requirePathExists + verifyWritable together catch the three failure + // modes: missing directory, ACL/readonly regression, or drive full. + // Missing path is always an alert (never "skipped") since the app + // requires logging everywhere. + var loggingPath = configuration["LoggingPath"]; + if (!string.IsNullOrWhiteSpace(loggingPath)) + { + builder.AddCheck( + "disk-space-logs", + new DiskSpaceHealthCheck( + explicitDrivePath: loggingPath, + requirePathExists: true, + verifyWritable: true), + tags: new[] { "ready" }); + } + + // AWS SSM Parameter Store. The app loads config from SSM at startup + // (.AddSystemsManager in Program.cs); this check verifies runtime + // reachability with the same SDK. + builder.AddCheck( + "aws-ssm", + new AwsSsmHealthCheck(), + tags: new[] { "ready" }); + + // "campus-*" groups checks for services we don't own - UCD directory, + // SSO, mail gateway, clinical data source - so the UI sort surfaces + // them together and separately from DB/disk/internal checks. + + // LDAP - UCD directory lookups (Classes/Utilities/LdapService.cs). + // Real LDAPS bind to ldap.ucdavis.edu:636 so a single probe covers + // TCP reachability, TLS/cert validity, and service-account auth. + // CA1416: LdapHealthCheck uses System.DirectoryServices.Protocols + // (Windows only). VIPER only runs on Windows/IIS, matching the + // existing pattern in Classes/Utilities/LdapService.cs. +#pragma warning disable CA1416 + builder.AddCheck( + "campus-ldap", + WithAdaptivePolling(new LdapHealthCheck()), + tags: new[] { "ready" }); +#pragma warning restore CA1416 + + // CAS - single sign-on. If this is down, nobody can log in. + // URL is environment-specific (ssodev in dev/test, cas in prod), + // read from Cas:CasBaseUrl. + var casBaseUrl = configuration["Cas:CasBaseUrl"]; + if (!string.IsNullOrWhiteSpace(casBaseUrl)) + { + // LazyInitializer: HealthCheckRegistration.Factory is invoked on + // every poll. Returning a fresh decorator each time would reset + // the cache - we need the same instance across calls so the + // adaptive-polling state persists. Pre-constructing at registration + // time isn't possible because IHttpClientFactory comes from DI. + AdaptivePollingHealthCheck? casCheck = null; + builder.Add(new HealthCheckRegistration( + "campus-cas", + sp => LazyInitializer.EnsureInitialized(ref casCheck, () => + WithAdaptivePolling(new HttpEndpointHealthCheck( + sp.GetRequiredService(), casBaseUrl, "CAS"))), + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "ready" })); + } + + // SMTP - email notifications (Services/EmailService.cs). MailKit probe + // that does Connect + NoOp + Disconnect so a single check covers TCP + // reachability, EHLO handshake, and STARTTLS/cert validation when + // EnableSsl is set. Mirrors EmailService's connect path minus DATA. + // Dev + Mailpit is treated as "skipped" when Mailpit is not running so + // developer dashboards aren't permanently red. + var smtpHost = configuration["EmailSettings:SmtpHost"]; + var smtpPort = configuration.GetValue("EmailSettings:SmtpPort") ?? 25; + if (!string.IsNullOrWhiteSpace(smtpHost)) + { + var enableSsl = configuration.GetValue("EmailSettings:EnableSsl"); + var useMailpit = configuration.GetValue("EmailSettings:UseMailpit"); + var mailpitDev = environment.IsDevelopment() && useMailpit; + var socketOptions = enableSsl && !mailpitDev + ? MailKit.Security.SecureSocketOptions.StartTls + : MailKit.Security.SecureSocketOptions.None; + + builder.AddCheck( + "campus-smtp", + WithAdaptivePolling(new SmtpHealthCheck( + smtpHost, + smtpPort, + socketOptions, + healthyWhenMissing: mailpitDev)), + tags: new[] { "ready" }); + } + + // VMACs - clinical data source (Areas/Directory/Services/VMACSService.cs + // and Areas/RAPS/Services/VMACSExport.cs). Simple HTTP probe. + // Same LazyInitializer pattern as campus-cas above - see that note. + AdaptivePollingHealthCheck? vmacsCheck = null; + builder.Add(new HealthCheckRegistration( + "campus-vmacs", + sp => LazyInitializer.EnsureInitialized(ref vmacsCheck, () => + WithAdaptivePolling(new HttpEndpointHealthCheck( + sp.GetRequiredService(), + "https://vmacs-vmth.vetmed.ucdavis.edu", + "VMACs"))), + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "ready" })); + + // The collector polls /health/detail at the public BaseUrl. The + // outbound HttpClient stamps a process-unique token header (see + // UseApiEndpointDelegatingHandler below) so the endpoint filter + // can recognize the self-call without widening the IP allowlist + // to cover whatever NAT'd source IP the loop-out produces. + // Dev has no BaseUrl configured, so fall back to a relative URL. + var baseUrl = configuration["EmailSettings:BaseUrl"]?.TrimEnd('/'); + var healthEndpointUrl = string.IsNullOrWhiteSpace(baseUrl) + ? "/health/detail" + : $"{baseUrl}/health/detail"; + services.AddTransient(); + services + .AddHealthChecksUI(setup => + { + setup.AddHealthCheckEndpoint("viper", healthEndpointUrl); + setup.SetEvaluationTimeInSeconds(300); + setup.MaximumHistoryEntriesPerEndpoint(50); + setup.UseApiEndpointDelegatingHandler(); + }) + .AddInMemoryStorage(); + + return services; + } + + /// + /// Wires the health-check endpoints and the UI dashboard into the pipeline, + /// including IP gating, duration-humanizer script injection, and the UI map. + /// Call AFTER UseRouting / UseAuthentication / UseSession. + /// + public static WebApplication UseViperHealthChecks(this WebApplication app) + { + // /health - bare liveness. Anonymous (Jenkins has no CAS creds). + app.MapHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => false, + }); + + // /health/detail - per-check JSON (UI format), IP-allowlisted to SVM + // infra via InternalAllowlist. Intentionally not CAS-gated so the + // endpoint stays reachable when auth subsystems are degraded. The + // in-app HealthChecksUI collector bypasses the IP check by sending + // a process-unique token header (HealthCheckCollectorAuth). + app.MapHealthChecks("/health/detail", new HealthCheckOptions + { + Predicate = c => c.Tags.Contains("ready"), + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, + }).AddEndpointFilter(async (ctx, next) => + { + var token = ctx.HttpContext.Request.Headers[HealthCheckCollectorAuth.HeaderName].FirstOrDefault(); + if (HealthCheckCollectorAuth.Matches(token)) + { + return await next(ctx); + } + if (!ClientIpFilterAttribute.IsClientIpSafe("InternalAllowlist")) + { + ctx.HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + return null; + } + return await next(ctx); + }); + + // IP-gate every UI sub-path (HTML page, API, resource files, webhook config). + app.UseWhen( + ctx => IsUIPath(ctx.Request.Path), + branch => branch.Use(async (ctx, next) => + { + if (!ClientIpFilterAttribute.IsClientIpSafe("InternalAllowlist")) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + await next(); + })); + + // Xabaril renders raw TimeSpan strings in the DURATION column; we can't + // configure this server-side. Inject a small JS that rewrites those + // cells as "243ms" / "2.19s" / "1m23s". Runs before MapHealthChecksUI + // so the middleware wraps the UI endpoint's response body. + app.Use(async (ctx, next) => + { + // StartsWithSegments handles trailing slashes ("/healthchecks/") + // without matching siblings like "/healthchecks-api"; we still + // gate on text/html below so JSON/asset responses aren't mangled. + if (!ctx.Request.Path.StartsWithSegments("/healthchecks")) + { + await next(); + return; + } + + var originalBody = ctx.Response.Body; + using var buffer = new MemoryStream(); + ctx.Response.Body = buffer; + try + { + await next(); + } + finally + { + // Restore before any downstream error handler runs, even if + // next() threw - leaving Response.Body pointing at the (soon + // disposed) MemoryStream breaks error-page middleware. + ctx.Response.Body = originalBody; + } + buffer.Seek(0, SeekOrigin.Begin); + + if (ctx.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true) + { + using var reader = new StreamReader(buffer, leaveOpen: true); + var html = await reader.ReadToEndAsync(); + var injected = html + .Replace("Health Checks UI", "Health Checks Status") + .Replace( + "", + $""); + var bytes = Encoding.UTF8.GetBytes(injected); + ctx.Response.ContentLength = bytes.Length; + await originalBody.WriteAsync(bytes); + } + else + { + await buffer.CopyToAsync(originalBody); + } + }); + + app.MapHealthChecksUI(options => + { + options.UIPath = "/healthchecks"; + options.ApiPath = "/healthchecks-api"; + options.AddCustomStylesheet(Path.Join( + app.Environment.ContentRootPath, + "wwwroot", "css", "healthchecks-ui-branding.css")); + }); + + return app; + } + + /// + /// Wraps a campus check in adaptive polling so expensive external + /// probes only fire once an hour while healthy, and every 5 min once + /// something is failing, so recovery is noticed on the next UI poll + /// cycle without hammering a degraded external service. At the 5-min + /// UI poll cadence (SetEvaluationTimeInSeconds), most polls for a + /// healthy check return the cached result cheaply; on failure, every + /// poll re-probes. + /// + private static AdaptivePollingHealthCheck WithAdaptivePolling(IHealthCheck inner) => + new AdaptivePollingHealthCheck( + inner, + healthyCacheDuration: TimeSpan.FromHours(1), + unhealthyCacheDuration: TimeSpan.FromMinutes(5)); + } +} diff --git a/web/Classes/HealthChecks/HttpEndpointHealthCheck.cs b/web/Classes/HealthChecks/HttpEndpointHealthCheck.cs new file mode 100644 index 00000000..25d2701f --- /dev/null +++ b/web/Classes/HealthChecks/HttpEndpointHealthCheck.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Probes an HTTP(S) endpoint with a short timeout. Treats any non-5xx + /// response (including 4xx) as Healthy - we're checking reachability, not + /// whether the endpoint accepts our request. 5xx or network failure = + /// Unhealthy. + /// + public class HttpEndpointHealthCheck : IHealthCheck + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly string _url; + private readonly string _displayName; + private readonly TimeSpan _timeout; + + public HttpEndpointHealthCheck( + IHttpClientFactory httpClientFactory, + string url, + string displayName, + TimeSpan? timeout = null) + { + _httpClientFactory = httpClientFactory; + _url = url; + _displayName = displayName; + _timeout = timeout ?? TimeSpan.FromSeconds(5); + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + using var client = _httpClientFactory.CreateClient(); + client.Timeout = _timeout; + + try + { + using var response = await client.GetAsync(_url, cancellationToken); + var code = (int)response.StatusCode; + return code < 500 + ? HealthCheckResult.Healthy($"{_displayName} reachable (HTTP {code}).") + : HealthCheckResult.Unhealthy($"{_displayName} returned HTTP {code}."); + } + catch (HttpRequestException ex) + { + return HealthCheckResult.Unhealthy( + $"{_displayName} unreachable: {ex.Message}"); + } + catch (TaskCanceledException) + { + return HealthCheckResult.Unhealthy($"{_displayName} timed out."); + } + } + } +} diff --git a/web/Classes/HealthChecks/LdapHealthCheck.cs b/web/Classes/HealthChecks/LdapHealthCheck.cs new file mode 100644 index 00000000..65deee03 --- /dev/null +++ b/web/Classes/HealthChecks/LdapHealthCheck.cs @@ -0,0 +1,66 @@ +using System.DirectoryServices.Protocols; +using System.Net; +using System.Runtime.Versioning; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Performs a real LDAPS bind using the same host, port, and service + /// credential as LdapService. One probe verifies TCP reachability, TLS + /// handshake (cert chain + hostname), and that the service account still + /// authenticates. + /// + [SupportedOSPlatform("windows")] + public class LdapHealthCheck : IHealthCheck + { + private const string _ldapUsername = "UID=vetmed,OU=Special Users,DC=ucdavis,DC=edu"; + private const string _ldapServer = "ldap.ucdavis.edu"; + private const int _ldapSSLPort = 636; + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var cred = HttpHelper.GetSetting("Credentials", "UCDavisDirectoryLDAP"); + if (string.IsNullOrEmpty(cred)) + { + return HealthCheckResult.Unhealthy( + "LDAP bind credential not configured (Credentials:UCDavisDirectoryLDAP)."); + } + + try + { + await Task.Run(() => + { + var ldapIdentifier = new LdapDirectoryIdentifier(_ldapServer, _ldapSSLPort); + using var lc = new LdapConnection( + ldapIdentifier, + new NetworkCredential(_ldapUsername, cred), + AuthType.Basic); + lc.SessionOptions.ProtocolVersion = 3; + lc.SessionOptions.SecureSocketLayer = true; + lc.Timeout = _timeout; + lc.Bind(); + }, cancellationToken); + + return HealthCheckResult.Healthy("LDAP bind succeeded."); + } + catch (LdapException ex) + { + return HealthCheckResult.Unhealthy( + $"LDAP bind failed: {ex.Message}", ex); + } + catch (DirectoryOperationException ex) + { + return HealthCheckResult.Unhealthy( + $"LDAP directory error: {ex.Message}", ex); + } + catch (OperationCanceledException) + { + return HealthCheckResult.Unhealthy("LDAP bind timed out."); + } + } + } +} diff --git a/web/Classes/HealthChecks/SmtpHealthCheck.cs b/web/Classes/HealthChecks/SmtpHealthCheck.cs new file mode 100644 index 00000000..7f0652c0 --- /dev/null +++ b/web/Classes/HealthChecks/SmtpHealthCheck.cs @@ -0,0 +1,88 @@ +using System.Net.Sockets; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Probes the SMTP relay with a MailKit Connect + NoOp + Disconnect. Proves + /// TCP reachability, EHLO handshake, and - when StartTls is configured - + /// STARTTLS negotiation with cert validation. Mirrors the connect path in + /// Services/EmailService.cs minus the DATA command. + /// + public class SmtpHealthCheck : IHealthCheck + { + private readonly string _host; + private readonly int _port; + private readonly SecureSocketOptions _socketOptions; + private readonly bool _healthyWhenMissing; + private readonly TimeSpan _timeout; + + /// + /// If true, a SocketException returns Healthy with a "skipped" description. + /// Use in Development where the SMTP target (e.g. Mailpit) may not be running. + /// + public SmtpHealthCheck( + string host, + int port, + SecureSocketOptions socketOptions, + bool healthyWhenMissing = false, + TimeSpan? timeout = null) + { + _host = host; + _port = port; + _socketOptions = socketOptions; + _healthyWhenMissing = healthyWhenMissing; + _timeout = timeout ?? TimeSpan.FromSeconds(5); + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_timeout); + + using var client = new SmtpClient + { + Timeout = (int)_timeout.TotalMilliseconds, + }; + + try + { + await client.ConnectAsync(_host, _port, _socketOptions, timeoutCts.Token); + await client.NoOpAsync(timeoutCts.Token); + await client.DisconnectAsync(true, timeoutCts.Token); + + return HealthCheckResult.Healthy("SMTP reachable (EHLO ok)."); + } + catch (SslHandshakeException ex) + { + return HealthCheckResult.Unhealthy( + $"SMTP TLS handshake failed: {ex.Message}", ex); + } + catch (SocketException ex) + { + return _healthyWhenMissing + ? HealthCheckResult.Healthy("SMTP not running (skipped).") + : HealthCheckResult.Unhealthy( + $"SMTP unreachable: {ex.SocketErrorCode}.", ex); + } + catch (SmtpCommandException ex) + { + return HealthCheckResult.Unhealthy( + $"SMTP command failed ({ex.StatusCode}): {ex.Message}", ex); + } + catch (SmtpProtocolException ex) + { + return HealthCheckResult.Unhealthy( + $"SMTP protocol error: {ex.Message}", ex); + } + catch (OperationCanceledException) + { + return HealthCheckResult.Unhealthy("SMTP timed out."); + } + } + } +} diff --git a/web/Program.cs b/web/Program.cs index f76f330b..d63125b2 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -22,6 +22,7 @@ using System.Xml.Linq; using Viper; using Viper.Classes; +using Viper.Classes.HealthChecks; using Viper.Classes.SQLContext; using Web; using Web.Authorization; @@ -74,12 +75,24 @@ logger.Fatal(ex, "Failed to get secrets from AWS"); } - //Use forwarded for headers on test and prod + //Use forwarded for headers on test and prod. Fetch CF networks once + //at startup so the closure below can reuse them without re-fetching. + var cloudflareCidrs = CloudflareNetworks.FetchOrFallback(logger); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownProxies.Add(IPAddress.Parse("192.168.56.134")); //The F5's internal IP + + // Cloudflare fronts vetmed.ucdavis.edu. The chain is + // User -> Cloudflare -> F5 -> app, so the middleware must walk two + // proxy hops to land on the real client IP. Default ForwardLimit + // is 1, which stops at the CF edge - bump to 2. + options.ForwardLimit = 2; + foreach (var cidr in cloudflareCidrs) + { + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr)); + } }); // Add services to the container. @@ -275,6 +288,9 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db builder.Services.AddRazorTemplating(); builder.Services.AddScoped(); + // All health-check DI wiring lives in HealthCheckExtensions. + builder.Services.AddViperHealthChecks(builder.Configuration, builder.Environment); + // Add HttpClient for Vite proxy (development only) if (builder.Environment.IsDevelopment()) { @@ -293,8 +309,13 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db var app = builder.Build(); - // Add Content Security Policy - app.UseCsp(csp => + // Add Content Security Policy. Skip for HealthChecks.UI paths - the bundled UI + // uses inline scripts and data: fonts that our strict CSP would block. Those + // paths are already IP-gated to trusted SVM admin subnets, so relaxing CSP + // there is acceptable. + app.UseWhen( + ctx => !Viper.Classes.HealthChecks.HealthCheckExtensions.IsUIPath(ctx.Request.Path), + branch => branch.UseCsp(csp => { // Allow JavaScript from: csp.AllowScripts @@ -353,7 +374,7 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db .FromSelf() // This domain .From("fonts.googleapis.com") // Google Fonts stylesheets .AllowUnsafeInline(); // Allows inline CSS - }); + })); // Configure the HTTP request pipeline. @@ -464,6 +485,9 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db app.UseCookiePolicy(); app.UseSession(); + // All health-check pipeline wiring lives in HealthCheckExtensions. + app.UseViperHealthChecks(); + // Define the default route mapping and require authentication by default (fail safe) app.MapControllerRoute( name: "areas", diff --git a/web/Viper.csproj b/web/Viper.csproj index e3601ebf..da13c22d 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -42,6 +42,10 @@ + + + + @@ -59,6 +63,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/web/appsettings.json b/web/appsettings.json index 949fa22e..a0e27d6a 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -16,7 +16,10 @@ "InternalAllowlist": [ "::1", // IPv6 localhost "127.0.0.1", // IPv4 localhost - "192.168.32.150" // Dan + "192.168.32.172", // Brandon + "192.168.33.1", // Jason + "192.168.32.195", // Keith + "192.168.32.253" // Rex ], "ExternalAllowlist": [] }, diff --git a/web/wwwroot/css/healthchecks-ui-branding.css b/web/wwwroot/css/healthchecks-ui-branding.css new file mode 100644 index 00000000..0432a0e0 --- /dev/null +++ b/web/wwwroot/css/healthchecks-ui-branding.css @@ -0,0 +1,60 @@ +/* + * HealthChecks.UI branding - UC Davis palette. + * Source: VueApp/src/config/colors.ts (hex values kept in sync by hand; + * the dashboard serves this file directly, no Vue build step applies). + */ +:root { + --primaryColor: #022851; /* Aggie Blue (ucdavis-blue-100) */ + --secondaryColor: #4b6983; /* ucdavis-blue-70 */ + --darkColor: #1d1d1d; + --midDarkColor: #335379; /* ucdavis-blue-80 */ + --grayColor: #4a4a4a; /* dark neutral - body text (AAA on white/lightColor) */ + --midGrayColor: #767676; /* medium gray - borders/secondary text (AA on white) */ + --lightColor: #f2f4f6; /* ucdavis-blue-10 - background only */ + --dangerColor: #79242f; /* Merlot */ + --warningColor: #ffc519; /* Aggie Gold */ + --successColor: #266041; /* Redwood */ + --logoImageUrl: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXkAAAHGCAYAAACRn/udAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF92lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNi4wLWMwMDYgNzkuMTY0NzUzLCAyMDIxLzAyLzE1LTExOjUyOjEzICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjIuMyAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjEtMDEtMjlUMTY6MDM6NTEtMDg6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIxLTAzLTE3VDE2OjU3OjEzLTA3OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIxLTAzLTE3VDE2OjU3OjEzLTA3OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjA2OGEzNjRkLWU5OWUtNGFlYS1hMmQwLTZkOWJiNzE3YWU2YyIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjE5MzU1ODYzLTM2ZTUtY2Q0Ny1iNmUyLTU1ZWYxOTQ1ZjM3NSIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjJjZWIxNDQzLWE4NDctNDRkMi04MjU3LWIwMWZhYjBiOGYxMSI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MmNlYjE0NDMtYTg0Ny00NGQyLTgyNTctYjAxZmFiMGI4ZjExIiBzdEV2dDp3aGVuPSIyMDIxLTAxLTI5VDE2OjAzOjUxLTA4OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjIuMyAoTWFjaW50b3NoKSIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MDY4YTM2NGQtZTk5ZS00YWVhLWEyZDAtNmQ5YmI3MTdhZTZjIiBzdEV2dDp3aGVuPSIyMDIxLTAzLTE3VDE2OjU3OjEzLTA3OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjIuMyAoTWFjaW50b3NoKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz6VQb+5AACGQElEQVR4nO2d7ZWsOJL3g+fs98tY0IwFzVrQtAXDWDCMBZNjwdAWbLYFS1uwXAuWa8FyLViuBUtZ8H8+SCoFQgIBIsms0u+cPFUJUuglIRQKvSUUiXxAAKRElMtPSkSZ/Njo5N+eiIYkSfrTMhaJPJjk6gxEIiGQSr0kokJ+fjoo8hsJ5d9GpR+JRCIXAaAC0MKfTn76DXEGALVsSCKRSCRyJgBSqXTHDYpaKfgKQG7IK+T1WoZZogGQXVPySCQS+eAAuC0o91oqbPW5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"); /* inlined via base64 so it resolves correctly under the /2 path base in TEST/PROD */ +} + +/* VIPER doesn't use webhook notifications; hide the nav item. */ +a[href="#/webhooks"] { + display: none; +} + +/* The expanded-row inner table header renders ~#626262 on ~#878888 (1.71:1) by + default - WCAG fail. Force Aggie Blue on white for clean contrast. */ +.hc-checks-table__header th { + background-color: var(--primaryColor); + color: #ffffff; +} + +/* Center the Tags column (header + body) for a tidier read. */ +.hc-checks-table__header th:nth-child(2), +.hc-checks-table tbody td:nth-child(2) { + text-align: center; +} + +/* Preserve newlines in description text so AdaptivePollingHealthCheck can + show a "Last checked: ..." timestamp on its own line below the original + description. Default whitespace handling collapses \n to a single space. */ +.hc-checks-table tbody td { + white-space: pre-line; +} + +/* Campus-status link: injected by healthchecks-ui-extras.js under the page + header when any "campus-*" check goes non-healthy. Amber (not red) because + the campus-* rows themselves already carry the failure indication - this + banner is a helpful pointer, not a second alarm. */ +#campus-status-banner { + margin: 0 2rem 1rem 2rem; + padding: 0.75rem 1rem; + background-color: #fff8e1; + border-left: 4px solid var(--warningColor); + color: var(--darkColor); +} + +#campus-status-banner a { + color: var(--primaryColor); + font-weight: bold; +} diff --git a/web/wwwroot/js/healthchecks-ui-extras.js b/web/wwwroot/js/healthchecks-ui-extras.js new file mode 100644 index 00000000..4ec390b3 --- /dev/null +++ b/web/wwwroot/js/healthchecks-ui-extras.js @@ -0,0 +1,96 @@ +/* + * Dashboard tweaks applied via injected script because Xabaril's UI bundle + * exposes no config knobs and we don't want to fork it. A MutationObserver + * re-runs the handlers on every re-render. + * + * 1. Humanize the DURATION column. The bundle renders raw TimeSpan strings + * like "00:00:02.1930578"; rewrite them as "243ms", "2.19s", or "1m23s". + * 2. Show a campus-status link under the header when any "campus-*" check + * is non-healthy, so operators can quickly check status.ucdavis.edu for + * a known UCD outage that might explain the failure. + */ +(function () { + const DURATION_PATTERN = /^\d+:\d+:\d+\.\d+$/; + const CAMPUS_STATUS_URL = "https://status.ucdavis.edu/"; + const BANNER_ID = "campus-status-banner"; + + function formatDuration(text) { + const m = text.match(/^(\d+):(\d+):(\d+)\.(\d+)$/); + if (!m) return text; + const hours = +m[1]; + const mins = +m[2]; + const secs = +m[3]; + // TimeSpan fractional part is 7 digits (100-ns ticks); normalise to ms + const frac = m[4].padEnd(7, "0").slice(0, 7); + const fracMs = +frac / 10000; + const totalMs = (hours * 3600 + mins * 60 + secs) * 1000 + fracMs; + + if (totalMs < 1000) return Math.round(totalMs) + "ms"; + if (totalMs < 60000) return (totalMs / 1000).toFixed(2) + "s"; + // Round to whole seconds first, then split, so 59.6s at the boundary + // rolls into the next minute instead of rendering as "1m60s". + const totalSec = Math.round(totalMs / 1000); + return Math.floor(totalSec / 60) + "m" + (totalSec % 60) + "s"; + } + + function reformatDurations() { + document.querySelectorAll("td").forEach(function (cell) { + const t = cell.textContent.trim(); + if (DURATION_PATTERN.test(t)) cell.textContent = formatDuration(t); + }); + } + + function hasUnhealthyCampusCheck() { + const rows = document.querySelectorAll("tr"); + for (const row of rows) { + const nameCell = row.querySelector("td"); + if (!nameCell) continue; + if (!nameCell.textContent.trim().startsWith("campus-")) continue; + const icon = row.querySelector(".hc-status .material-icons"); + // check_circle = Healthy; anything else (error, warning, cancel) is a problem + if (icon && icon.textContent.trim() !== "check_circle") return true; + } + return false; + } + + function updateCampusBanner() { + const existing = document.getElementById(BANNER_ID); + if (hasUnhealthyCampusCheck()) { + if (existing) return; + const header = document.querySelector(".hc-liveness__header"); + if (!header) return; + const banner = document.createElement("div"); + banner.id = BANNER_ID; + const link = document.createElement("a"); + link.href = CAMPUS_STATUS_URL; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = "Check UC Davis campus status"; + banner.appendChild( + document.createTextNode("One or more campus services are reporting issues. ")); + banner.appendChild(link); + banner.appendChild(document.createTextNode(".")); + header.insertAdjacentElement("afterend", banner); + } else if (existing) { + existing.remove(); + } + } + + function onMutation() { + reformatDurations(); + updateCampusBanner(); + } + + const obs = new MutationObserver(onMutation); + + function start() { + onMutation(); + obs.observe(document.body, { childList: true, subtree: true, characterData: true }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})();