Compare commits

...

20 Commits

Author SHA1 Message Date
8327c0fd0b Update cache query parameter in HealthCheckHtmlGenerator
Modified the `HealthCheckHtmlGenerator` class to update the
query parameter name in the URLs for the CSS and JavaScript
files. Changed `?{CacheId}` to `?cache={CacheId}` for both
`health-ui.css` and `health-ui.js` to ensure consistency and
improve clarity or compatibility.
2026-07-13 14:21:42 +02:00
98f8dc8710 Add cache-busting to health check UI assets
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.
2026-07-13 14:21:07 +02:00
5f251c1209 Reduce auto-refresh countdown from 10s to 3s
Updated the `HealthCheckHtmlGenerator` class to change the HTML auto-refresh countdown display from 10 seconds to 3 seconds. Adjusted the `countdown` variable in `health-ui.js` to initialize with 3 seconds, ensuring consistency between the frontend logic and the displayed countdown timer.
2026-07-13 14:14:53 +02:00
9537954626 Improve ProfileWorker health-check logic and initialization
Refactor the `ProfileWorker` class to enhance health-check handling, including support for an uninitialized state. The `_lastSuccessfulRun` field was changed to a nullable `DateTime?` to represent when the service has not yet completed its first successful run.

Introduce a new health state, **Degraded (Initializing)**, to indicate the service is starting up. Update the `CheckHealthAsync` method to handle this state and return a `HealthCheckResult.Degraded` with relevant metadata.

Refine health-check logic to safely access `_lastSuccessfulRun.Value` only when initialized. Update documentation to clarify health status conditions and retain adaptive multiplier logic for detecting issues based on interval length.

Enhance `HealthCheckResult` metadata with additional details, including `LastSuccessfulRun`, time since last success, and consecutive failures. Improve comments and descriptions for better clarity.
2026-07-13 14:03:49 +02:00
275b06fedb Update health-check logic with adaptive thresholds
Replaced the fixed 3x interval multiplier with an adaptive threshold
based on the configured interval duration. Health states (`Healthy`,
`Degraded`, `Unhealthy`) were redefined to reflect this change.

- Adaptive multiplier: 3x for fast intervals (<10s), 1.5x for slower
  intervals (≥10s), ensuring faster problem detection for longer
  intervals and tolerance for jitter in shorter intervals.
- Updated `CheckHealthAsync` to calculate `maxAllowedDelay` using the
  adaptive multiplier.
- Added `Multiplier` and `MaxAllowedDelay` to diagnostic data for
  better monitoring and debugging.
- Improved documentation and comments to explain the new logic.
2026-07-13 13:54:58 +02:00
bc881bf70f Add XML documentation for improved code clarity
Added detailed XML documentation across multiple files to enhance
code maintainability and readability. Key updates include:

- Documented `AllowAllDashboardAuthorizationFilter` to clarify
  its development-only usage.
- Added comments to `DtoExtensions` for Hangfire job ID generation
  and DTO-to-command conversion methods.
- Enhanced `HealthCheckHtmlGenerator` with detailed descriptions
  of HTML generation methods and utility functions.
- Documented `DependencyInjection` and `ProfileWorkerOptionsValidator`
  to explain service registration and configuration validation.
- Updated `ProfileCache` with comments on thread-safe operations.
- Added documentation to `ProfileWork` for profile synchronization
  logic and execution flow.
- Enhanced `ProfileWorker` with health check logic and background
  service execution details.
- Documented `ProfileWorkerOptions` configuration properties.

These changes aim to improve developer understanding and ensure
best practices are followed in production environments.
2026-07-13 13:37:12 +02:00
84b95c53e4 Update ProfileWorker interval in appsettings.json
Increased the `IntervalMS` value in the `ProfileWorker` section of `appsettings.json` from 1000ms (1 second) to 60000ms (60 seconds). This change reduces the execution frequency of the `ProfileWorker` process, likely to optimize performance or reduce resource usage.
2026-07-13 13:25:57 +02:00
511fb159c4 Update Hangfire Dashboard Authorization syntax
Updated the `Authorization` property in `app.UseHangfireDashboard`
to use modern C# collection initialization syntax (square brackets)
instead of the older array initialization syntax (curly braces).
This change ensures consistency with modern C# conventions.
2026-07-13 13:23:07 +02:00
4af0ed7d14 Update project metadata and enable documentation file
Replaced `<TargetFramework>` with `<TargetFrameworks>` for future multi-targeting support. Added `<GenerateDocumentationFile>` to enable XML documentation generation. Included metadata properties such as `<PackageId>`, `<Authors>`, `<Company>`, and versioning details to enhance package identity. Added `<PackageTags>` for discoverability and `<UserSecretsId>` for secure development secrets management.
2026-07-13 13:19:42 +02:00
0fd6a97968 Refactor health check HTML generation logic
Centralized health check HTML generation into a new static
`HealthCheckHtmlGenerator` class to improve modularity and
maintainability. This class encapsulates the logic for generating
HTML, including navigation, overall status, summary cards,
individual checks, and refresh information.

Replaced the inline `GenerateHealthCheckHtml` method in `Program.cs`
with the new `HealthCheckHtmlGenerator.Generate` method, removing
redundant code and improving separation of concerns. Updated
`Program.cs` to include the necessary namespace.

Simplified the `/health-ui` endpoint to use the new utility class,
reducing code duplication and improving readability.
2026-07-13 13:10:55 +02:00
cec9cfe2ed Refactor Health UI and enable static file support
- Added `app.UseStaticFiles()` to serve static files from `wwwroot`.
- Changed root path redirection to `/health-ui`.
- Moved inline CSS/JS from `Program.cs` to `health-ui.css` and `health-ui.js`.
- Updated navigation links in the health check HTML.
- Enhanced Health UI with improved styles and animations.
- Introduced countdown auto-refresh in `health-ui.js`.
- Updated `launchSettings.json` to start at the root URL.
2026-07-13 13:08:22 +02:00
eb2514f1fc Add health check UI and Serilog dashboard integration
Introduced a `/health-ui` route with a custom HTML-based health check UI, leveraging `HealthCheckService`. Added `GenerateHealthCheckHtml` to dynamically render health check results with Bootstrap styling, auto-refresh, and detailed status reporting.

Enabled Serilog self-diagnostics and integrated `app.UseSerilogUi()` for a Serilog log dashboard. Enhanced user experience with navigation links, animations, and responsive design. Improved observability and monitoring capabilities.
2026-07-13 13:02:11 +02:00
8a05d86285 feat: Add health check monitoring for ProfileWorker
ProfileWorker changes:
- Implement IHealthCheck interface
- Replace _workCount with DateTime-based tracking
- Track _lastSuccessfulRun, _consecutiveFailures, _lastException
- Graceful shutdown handling (OperationCanceledException)
- Health states: Healthy, Degraded (1-2 failures), Unhealthy (3x interval)

DependencyInjection changes:
- Register ProfileWorker as singleton (for health check access)
- Use factory pattern for IHostedService registration

Program.cs changes:
- Add health check service with ProfileWorker
- Map /health endpoint (full JSON response with all checks)
- Map /health/ready endpoint (filtered by 'ready' tag)
- Custom JSON response writer with detailed metrics
2026-07-13 12:19:11 +02:00
b48e3d1823 config: Add ProfileWorker configuration section
- ProfileWorker:IntervalMS = 1000 (1 second sync interval)
- Configurable via appsettings.json with IOptions pattern
- Validated at startup via ProfileWorkerOptionsValidator
2026-07-13 11:59:59 +02:00
41b08e3454 refactor: Use AddProfileWorker extension method in Program.cs
- Replace direct AddHostedService<ProfileManager> with AddProfileWorker()
- Enables IOptions configuration and validation at startup
- Add using directive for ECMJobRunner.WebCron.ProfileWorker namespace
2026-07-13 11:59:52 +02:00
2fd99694b6 refactor: Remove monolithic ProfileManager
Replaced by modular ProfileWorker namespace with better separation of concerns.
2026-07-13 11:59:44 +02:00
66fad12e63 feat: Add ProfileWork with Hangfire job synchronization
- ProfileWork.cs: Business logic for DB-to-Hangfire sync
- Fetches active profiles from database via MediatR
- Registers/updates Hangfire recurring jobs with local timezone
- Removes stale jobs (deleted from DB)
- O(n) performance optimization using HashSet for lookups
- Uses ProfileWorker's stopping token for graceful shutdown

Job lifecycle:
- ProfileWorker stops → All running jobs cancelled via token closure
- Jobs use RecurringJobOptions with TimeZoneInfo.Local
- Automatic retry support via Hangfire [AutomaticRetry] attribute
2026-07-13 11:59:38 +02:00
f67c321380 refactor: Extract ProfileWorker into modular components
- Create ProfileWorker namespace with 4 separate components
- ProfileWorker.cs: BackgroundService orchestrator with IOptions support
- ProfileWorkerOptions.cs: Configuration model with validation
- ProfileCache.cs: Thread-safe cache using composition pattern
- DependencyInjection.cs: Service registration with IValidateOptions

Benefits:
- Separation of concerns (orchestration, config, state, DI)
- IOptions pattern for appsettings.json configuration
- Startup validation for configuration errors
- Better testability and maintainability
2026-07-13 11:59:23 +02:00
cba1aa85d0 feat: Add root path redirect to Hangfire dashboard
- Map root path (/) to redirect to /hangfire dashboard
- Improves user experience by providing direct access to main dashboard
- Users accessing the application root will be automatically redirected to Hangfire
2026-07-13 09:51:53 +02:00
def4d7b7c7 refactor: Move log directory configuration to Application section
- Move LogDirectory from Logging section to Application section in appsettings.json
- Update Program.cs to read from Application:LogDirectory instead of Logging:LogDirectory
- Fix JSON schema validation warning for non-standard Logging properties
- Application section now holds custom application-specific settings
- Standard Logging and Serilog sections remain clean and schema-compliant
2026-07-13 09:51:01 +02:00
15 changed files with 1004 additions and 85 deletions

View File

@@ -8,6 +8,11 @@ namespace ECMJobRunner.WebCron
/// </summary>
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
{
/// <summary>
/// Determines whether the current user is authorized to access the Hangfire Dashboard.
/// </summary>
/// <param name="context">The Hangfire dashboard context containing request information.</param>
/// <returns>Always returns <c>true</c> to allow all users (development only).</returns>
public bool Authorize(DashboardContext context)
{
// Allow all users - FOR DEVELOPMENT ONLY

View File

@@ -1,9 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>ECMJobRunner.WebCron</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.WebCron</Product>
<Version>1.0.0</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<InformationalVersion>1.0.0</InformationalVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<PackageTags>digital data job runner</PackageTags>
<UserSecretsId>cf893b96-c71a-4a96-a6a7-40004249e1a3</UserSecretsId>
</PropertyGroup>
<ItemGroup>

View File

@@ -4,13 +4,30 @@ using MediatR;
namespace ECMJobRunner.WebCron.Extensions;
/// <summary>
/// Extension methods for profile DTOs to support Hangfire job operations.
/// </summary>
public static class DtoExtensions
{
/// <summary>
/// Generates a unique Hangfire job identifier for a profile.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A unique job identifier in the format "profile-{Id}-{normalized-name}".</returns>
/// <example>
/// Example: profile with Id=123 and ProfileName="Import Data"
/// returns "profile-123-import_data"
/// </example>
public static string JobId(this CfgProfileDto profile)
{
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
}
/// <summary>
/// Converts a profile DTO to a DEX job batch command.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A <see cref="TriggeringDEXJobBatchCommand"/> ready to execute the profile job.</returns>
public static TriggeringDEXJobBatchCommand ToJob(this CfgProfileDto profile)
{
return new TriggeringDEXJobBatchCommand

View File

@@ -0,0 +1,337 @@
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?cache={CacheId}"" />
<script src=""/js/health-ui.js?cache={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";
}
}

View File

@@ -1,71 +0,0 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.WebCron.Extensions;
using Hangfire;
using MediatR;
using System.Collections.Concurrent;
namespace ECMJobRunner.WebCron
{
public class ProfileManager(ILogger<ProfileManager> Logger, IServiceScopeFactory ScopeFactory, IRecurringJobManager JobManager) : BackgroundService
{
private readonly ConcurrentDictionary<string, CfgProfileDto> Profiles = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
if (Logger.IsEnabled(LogLevel.Information))
{
Logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
using var scope = ScopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var profiles = await mediator.Send(new GetProfileQuery()
{
Active = true,
IncludeSqlJobs = true
}, stoppingToken);
foreach (var profile in profiles)
{
if (Profiles.TryGetValue(profile.JobId(), out var currentProfile)
&& currentProfile.Schedule == profile.Schedule)
continue;
// Add or update recurring job using MediatR command
JobManager.AddOrUpdate<IMediator>(
profile.JobId(),
mediator => mediator.Send(profile.ToJob(), CancellationToken.None),
profile.Schedule,
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Local
}
);
// Store/update in local cache
Profiles[profile.JobId()] = profile;
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
profile.JobId(), profile.Schedule);
}
await Task.Delay(1000, stoppingToken);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred in ProfileManager.");
}
}
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Extension methods for configuring ProfileWorker services in the DI container.
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// Registers ProfileWorker background service and related dependencies.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configuration">Application configuration containing ProfileWorker settings.</param>
/// <returns>The configured service collection for method chaining.</returns>
/// <remarks>
/// Registers the following services:
/// <list type="bullet">
/// <item><description><see cref="ProfileWorkerOptions"/> - Configuration options from appsettings.json</description></item>
/// <item><description><see cref="ProfileWorker"/> - Singleton background service (also registered as IHostedService)</description></item>
/// <item><description><see cref="ProfileCache"/> - Singleton cache for profile state</description></item>
/// <item><description><see cref="ProfileWork"/> - Scoped service for profile synchronization logic</description></item>
/// </list>
/// </remarks>
public static IServiceCollection AddProfileWorker(this IServiceCollection services, IConfiguration configuration)
{
// Configure ProfileWorker options from appsettings.json
services.Configure<ProfileWorkerOptions>(
configuration.GetSection(ProfileWorkerOptions.SectionName));
// Validate options at startup
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
// Register ProfileWorker as both HostedService and singleton (for health check access)
services.AddSingleton<ProfileWorker>();
services.AddHostedService(sp => sp.GetRequiredService<ProfileWorker>());
services.AddSingleton<ProfileCache>();
services.AddScoped<ProfileWork>();
return services;
}
}
/// <summary>
/// Validates <see cref="ProfileWorkerOptions"/> configuration at application startup.
/// Ensures IntervalMS is within acceptable bounds to prevent misconfiguration.
/// </summary>
internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOptions>
{
/// <summary>
/// Validates the ProfileWorker options.
/// </summary>
/// <param name="name">The name of the options instance (not used).</param>
/// <param name="options">The options to validate.</param>
/// <returns>
/// <see cref="ValidateOptionsResult.Success"/> if valid,
/// or <see cref="ValidateOptionsResult.Fail"/> with error message if invalid.
/// </returns>
/// <remarks>
/// Validation rules:
/// <list type="bullet">
/// <item><description>IntervalMS must be greater than 0</description></item>
/// <item><description>IntervalMS should be at least 100ms to avoid excessive CPU usage</description></item>
/// </list>
/// </remarks>
public ValidateOptionsResult Validate(string? name, ProfileWorkerOptions options)
{
if (options.IntervalMS <= 0)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs must be greater than 0");
}
if (options.IntervalMS < 100)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs should be at least 100ms to avoid excessive CPU usage");
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -0,0 +1,50 @@
using ECMJobRunner.Application.Common.Dtos;
using System.Collections.Concurrent;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Thread-safe cache for storing active profile configurations.
/// Uses <see cref="ConcurrentDictionary{TKey, TValue}"/> to track profile state
/// and detect changes in schedule or removal.
/// </summary>
public class ProfileCache
{
private readonly ConcurrentDictionary<string, CfgProfileDto> _cache = new();
/// <summary>
/// Retrieves a profile from the cache by job identifier.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <returns>The cached profile, or <c>null</c> if not found.</returns>
public CfgProfileDto? Get(string jobId) => _cache.TryGetValue(jobId, out var profile) ? profile : null;
/// <summary>
/// Adds a new profile or updates an existing profile in the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The profile configuration to cache.</param>
public void AddOrUpdate(string jobId, CfgProfileDto profile) => _cache[jobId] = profile;
/// <summary>
/// Attempts to remove a profile from the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The removed profile, or <c>null</c> if not found.</param>
/// <returns><c>true</c> if the profile was removed; otherwise, <c>false</c>.</returns>
public bool TryRemove(string jobId, out CfgProfileDto? profile) => _cache.TryRemove(jobId, out profile);
/// <summary>
/// Gets all job identifiers currently stored in the cache.
/// </summary>
/// <returns>A collection of job identifiers.</returns>
public IEnumerable<string> GetAllJobIds() => _cache.Keys;
/// <summary>
/// Attempts to retrieve a profile from the cache.
/// </summary>
/// <param name="jobId">The unique job identifier.</param>
/// <param name="profile">The cached profile, or <c>null</c> if not found.</param>
/// <returns><c>true</c> if the profile was found; otherwise, <c>false</c>.</returns>
public bool TryGetValue(string jobId, out CfgProfileDto? profile) => _cache.TryGetValue(jobId, out profile);
}

View File

@@ -0,0 +1,82 @@
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.WebCron.Extensions;
using Hangfire;
using MediatR;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Handles the core business logic for synchronizing profiles between the database and Hangfire.
/// Responsible for adding, updating, and removing recurring jobs based on profile configuration.
/// </summary>
/// <param name="Logger">Logger for diagnostic output.</param>
/// <param name="JobManager">Hangfire recurring job manager.</param>
/// <param name="Mediator">MediatR mediator for executing commands and queries.</param>
/// <param name="ProfileCache">Thread-safe cache for tracking profile state.</param>
public class ProfileWork(ILogger<ProfileWork> Logger, IRecurringJobManager JobManager, IMediator Mediator, ProfileCache ProfileCache)
{
/// <summary>
/// Synchronizes active profiles from the database with Hangfire recurring jobs.
/// </summary>
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
/// <remarks>
/// Execution flow:
/// <list type="number">
/// <item><description>Fetches active profiles from database via MediatR query</description></item>
/// <item><description>Removes jobs from Hangfire that no longer exist in the database</description></item>
/// <item><description>Adds or updates jobs with changed schedules</description></item>
/// <item><description>Updates local cache to reflect current state</description></item>
/// </list>
/// Uses HashSet for O(1) job existence checks to optimize performance.
/// </remarks>
/// <returns>A task representing the asynchronous synchronization operation.</returns>
public async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Fetch active profiles from database
var profiles = await Mediator.Send(new GetProfileQuery()
{
Active = true,
IncludeSqlJobs = true
}, stoppingToken);
// Create HashSet for O(1) lookup performance
var profileJobIds = profiles.Select(p => p.JobId()).ToHashSet();
// Remove jobs from Hangfire that no longer exist in the database
foreach (var cachedJobId in ProfileCache.GetAllJobIds())
{
if (!profileJobIds.Contains(cachedJobId))
{
// Remove job from Hangfire if it no longer exists in the database
JobManager.RemoveIfExists(cachedJobId);
ProfileCache.TryRemove(cachedJobId, out _);
Logger.LogInformation("Job {JobId} removed", cachedJobId);
}
}
// Add or update jobs in Hangfire based on the database profiles
foreach (var profile in profiles)
{
if (ProfileCache.TryGetValue(profile.JobId(), out var currentProfile)
&& currentProfile!.Schedule == profile.Schedule)
continue;
// Add or update recurring job using MediatR command
JobManager.AddOrUpdate<IMediator>(
profile.JobId(),
mediator => mediator.Send(profile.ToJob(), stoppingToken),
profile.Schedule,
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Local
}
);
// Store/update in local cache
ProfileCache.AddOrUpdate(profile.JobId(), profile);
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
profile.JobId(), profile.Schedule);
}
}
}

View File

@@ -0,0 +1,211 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Background service that continuously synchronizes active profiles with Hangfire recurring jobs.
/// Implements health check monitoring to track service status and failure conditions.
/// </summary>
/// <param name="Logger">Logger for diagnostic output.</param>
/// <param name="ScopeFactory">Factory for creating service scopes (required for scoped service resolution).</param>
/// <param name="Options">Configuration options for interval timing.</param>
/// <remarks>
/// This service runs on a configurable interval (default 1 second) and performs the following:
/// <list type="bullet">
/// <item><description>Fetches active profiles from the database</description></item>
/// <item><description>Synchronizes profiles with Hangfire recurring jobs</description></item>
/// <item><description>Tracks health status based on success/failure patterns</description></item>
/// <item><description>Gracefully handles cancellation during application shutdown</description></item>
/// </list>
/// Health states (with adaptive thresholds):
/// <list type="bullet">
/// <item><description><b>Degraded (Initializing)</b>: Service starting up, waiting for first successful run</description></item>
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
/// <item><description><b>Degraded</b>: 1+ consecutive failures but within time threshold</description></item>
/// <item><description><b>Unhealthy</b>: No success within adaptive threshold (3x interval for fast intervals &lt;10s, 1.5x for slower intervals)</description></item>
/// </list>
/// The adaptive multiplier ensures fast problem detection when using longer intervals (e.g., 60s interval = 90s timeout)
/// while maintaining tolerance for network jitter on short intervals (e.g., 1s interval = 3s timeout).
/// </remarks>
public class ProfileWorker(
ILogger<ProfileWorker> Logger,
IServiceScopeFactory ScopeFactory,
IOptions<ProfileWorkerOptions> Options) : BackgroundService, IHealthCheck
{
private readonly ProfileWorkerOptions _options = Options.Value;
// Health check state
private DateTime? _lastSuccessfulRun = null; // null = not run yet
private int _consecutiveFailures = 0;
private Exception? _lastException = null;
/// <summary>
/// Background service execution loop that synchronizes profiles on a configurable interval.
/// </summary>
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
/// <returns>A task representing the background execution.</returns>
/// <remarks>
/// The loop continues until:
/// <list type="bullet">
/// <item><description>Application shutdown is requested (via stoppingToken)</description></item>
/// <item><description>An unhandled exception causes service failure</description></item>
/// </list>
/// Uses scoped services for each iteration to ensure proper lifetime management.
/// </remarks>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Logger.LogInformation("ProfileWorker started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
using var scope = ScopeFactory.CreateScope();
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
await work.ExecuteAsync(stoppingToken);
// Success - update health state
_lastSuccessfulRun = DateTime.UtcNow;
_consecutiveFailures = 0;
_lastException = null;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ProfileWorker sync completed successfully at {time}", _lastSuccessfulRun);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful shutdown - app is stopping
Logger.LogInformation("ProfileWorker stopping due to cancellation request");
break;
}
catch (Exception ex)
{
// Unexpected error - track for health check
_consecutiveFailures++;
_lastException = ex;
Logger.LogError(ex,
"An unexpected error occurred in ProfileWorker (consecutive failures: {failures})",
_consecutiveFailures);
}
await Task.Delay(_options.IntervalMS, stoppingToken);
}
Logger.LogInformation("ProfileWorker stopped");
}
/// <summary>
/// Performs a health check by evaluating recent success/failure patterns.
/// </summary>
/// <param name="context">Health check context (unused).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A <see cref="HealthCheckResult"/> indicating the current health status:
/// <list type="bullet">
/// <item><description><see cref="HealthStatus.Healthy"/> - Service running normally</description></item>
/// <item><description><see cref="HealthStatus.Degraded"/> - Recent failures but still operational, or service initializing</description></item>
/// <item><description><see cref="HealthStatus.Unhealthy"/> - No successful run for extended period</description></item>
/// </list>
/// </returns>
/// <remarks>
/// Health determination logic:
/// <list type="number">
/// <item><description><b>Degraded (Initializing)</b>: Service has not completed its first successful run yet</description></item>
/// <item><description><b>Unhealthy</b>: Time since last success exceeds adaptive threshold (3x interval for fast intervals &lt;10s, 1.5x for slower intervals)</description></item>
/// <item><description><b>Degraded</b>: 1+ consecutive failures within time threshold</description></item>
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
/// </list>
/// Adaptive multiplier ensures fast problem detection for longer intervals while maintaining tolerance for short intervals.
/// </remarks>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
// Service hasn't completed its first successful run yet
if (_lastSuccessfulRun == null)
{
var data = new Dictionary<string, object>
{
["Status"] = "Initializing",
["ConsecutiveFailures"] = _consecutiveFailures,
["IntervalMS"] = _options.IntervalMS
};
if (_lastException != null)
{
data["LastException"] = _lastException.Message;
}
// Degraded during initialization (not unhealthy, service is starting up)
return Task.FromResult(HealthCheckResult.Degraded(
_consecutiveFailures > 0
? $"ProfileWorker initializing with {_consecutiveFailures} failure(s)"
: "ProfileWorker is initializing, waiting for first successful run",
_lastException,
data
));
}
var timeSinceLastSuccess = DateTime.UtcNow - _lastSuccessfulRun.Value;
// Adaptive multiplier: 3x for fast intervals (<10s), 1.5x for slower intervals
// This ensures faster problem detection when using longer intervals (e.g., 60s)
var multiplier = _options.IntervalMS < 10000 ? 3.0 : 1.5;
var maxAllowedDelay = TimeSpan.FromMilliseconds(_options.IntervalMS * multiplier);
// Unhealthy: No successful run within adaptive threshold
if (timeSinceLastSuccess > maxAllowedDelay)
{
var data = new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
["TimeSinceLastSuccess"] = timeSinceLastSuccess,
["ConsecutiveFailures"] = _consecutiveFailures,
["IntervalMS"] = _options.IntervalMS,
["Multiplier"] = multiplier,
["MaxAllowedDelay"] = maxAllowedDelay
};
if (_lastException != null)
{
data["LastException"] = _lastException.Message;
}
return Task.FromResult(HealthCheckResult.Unhealthy(
$"ProfileWorker has not completed successfully for {timeSinceLastSuccess.TotalSeconds:F0} seconds ({_consecutiveFailures} consecutive failures)",
_lastException,
data
));
}
// Degraded: 1+ consecutive failures but within time limit
if (_consecutiveFailures > 0)
{
var data = new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
["ConsecutiveFailures"] = _consecutiveFailures
};
return Task.FromResult(HealthCheckResult.Degraded(
$"ProfileWorker has {_consecutiveFailures} consecutive failure(s) but still operational",
null,
data
));
}
// Healthy
return Task.FromResult(HealthCheckResult.Healthy(
"ProfileWorker is running normally",
new Dictionary<string, object>
{
["LastSuccessfulRun"] = _lastSuccessfulRun.Value
}
));
}
}

View File

@@ -0,0 +1,23 @@
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Configuration options for ProfileWorker background service.
/// Binds to "ProfileWorker" section in appsettings.json.
/// </summary>
public class ProfileWorkerOptions
{
/// <summary>
/// Configuration section name for binding options.
/// </summary>
public const string SectionName = "ProfileWorker";
/// <summary>
/// Interval in milliseconds between profile synchronization checks.
/// Default: 1000ms (1 second).
/// </summary>
/// <remarks>
/// Lower values increase responsiveness but consume more resources.
/// Recommended range: 1000-5000ms.
/// </remarks>
public int IntervalMS { get; set; } = 1000;
}

View File

@@ -1,9 +1,12 @@
using ECMJobRunner.Application;
using ECMJobRunner.Infrastructure;
using ECMJobRunner.WebCron;
using ECMJobRunner.WebCron.HealthCheck;
using ECMJobRunner.WebCron.ProfileWorker;
using Hangfire;
using Hangfire.SqlServer;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
@@ -19,8 +22,8 @@ var tempConfig = new ConfigurationBuilder()
.Build();
// Get log directory from configuration
var logDirectory = tempConfig.GetValue<string>("Logging:LogDirectory")
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
var logDirectory = tempConfig.GetValue<string>("Application:LogDirectory")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
@@ -53,7 +56,7 @@ try
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddHostedService<ProfileManager>();
builder.Services.AddProfileWorker(builder.Configuration);
// Register services
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
@@ -100,9 +103,13 @@ builder.Services.AddHangfireServer();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<ProfileWorker>("profile-worker", tags: new[] { "ready", "worker" });
// Add Serilog.UI with SQLite provider - use same path from configuration
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Logging:LogDirectory")
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
builder.Services.AddSerilogUi(logUIOpt =>
@@ -125,12 +132,28 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// Enable static files for wwwroot
app.UseStaticFiles();
app.UseAuthorization();
// Add Hangfire Dashboard with no authentication (for development)
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new AllowAllDashboardAuthorizationFilter() }
Authorization = [new AllowAllDashboardAuthorizationFilter()]
});
// Add Health Check UI route (accessible outside Hangfire dashboard)
app.MapGet("/health-ui", async (HealthCheckService healthCheckService, HttpContext context) =>
{
var report = await healthCheckService.CheckHealthAsync(context.RequestAborted);
var html = HealthCheckHtmlGenerator.Generate(report);
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(html);
return Results.Empty;
});
// Add Serilog.UI Dashboard
@@ -138,6 +161,39 @@ app.UseSerilogUi();
app.MapControllers();
// Map health check endpoints
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => true,
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = System.Text.Json.JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
timestamp = DateTime.UtcNow,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds,
exception = e.Value.Exception?.Message,
data = e.Value.Data
})
});
await context.Response.WriteAsync(result);
}
});
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
// Redirect root path to Health UI
app.MapGet("/", () => Results.Redirect("/health-ui"));
app.Run();
}
catch (Exception ex)

View File

@@ -13,7 +13,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "hangfire",
"launchUrl": "",
"applicationUrl": "http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -23,7 +23,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "hangfire",
"launchUrl": "",
"applicationUrl": "https://localhost:7027;http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -32,7 +32,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "hangfire",
"launchUrl": "",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -3,8 +3,7 @@
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
}
},
"ConnectionStrings": {
"SDD-VMP04-SQL17": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
@@ -18,5 +17,11 @@
},
"ReC": {
"ApiUrl": "http://172.24.12.39:90"
},
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
},
"ProfileWorker": {
"IntervalMS": 60000
}
}

View File

@@ -0,0 +1,96 @@
body {
padding: 40px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container-fluid { max-width: 1400px; }
.status-healthy { color: #198754; }
.status-degraded { color: #ffc107; }
.status-unhealthy { color: #dc3545; }
.badge-healthy { background-color: #198754; }
.badge-degraded { background-color: #ffc107; color: #000; }
.badge-unhealthy { background-color: #dc3545; }
.card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border: none;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.card-header {
font-weight: 600;
border-radius: 12px 12px 0 0 !important;
padding: 1rem 1.5rem;
}
.metric {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.metric:last-child { border-bottom: none; }
.metric-label { font-weight: 500; color: #666; }
.metric-value { color: #333; font-weight: 600; }
.refresh-info {
position: fixed;
bottom: 20px;
right: 20px;
background: white;
padding: 12px 20px;
border-radius: 50px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-size: 14px;
font-weight: 500;
}
.spinner {
animation: spin 2s linear infinite;
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.header-card {
background: white;
border-radius: 16px;
padding: 2rem;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.nav-links {
background: white;
border-radius: 12px;
padding: 1rem;
margin-bottom: 20px;
}
.nav-links a {
margin-right: 15px;
text-decoration: none;
font-weight: 500;
}

View File

@@ -0,0 +1,15 @@
let countdown = 3;
function updateCountdown() {
document.getElementById('countdown').innerText = countdown;
countdown--;
if (countdown < 0) {
location.reload();
}
}
function autoRefresh() {
setInterval(updateCountdown, 1000);
}
window.onload = autoRefresh;