Compare commits
20 Commits
d61d14145a
...
8327c0fd0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8327c0fd0b | |||
| 98f8dc8710 | |||
| 5f251c1209 | |||
| 9537954626 | |||
| 275b06fedb | |||
| bc881bf70f | |||
| 84b95c53e4 | |||
| 511fb159c4 | |||
| 4af0ed7d14 | |||
| 0fd6a97968 | |||
| cec9cfe2ed | |||
| eb2514f1fc | |||
| 8a05d86285 | |||
| b48e3d1823 | |||
| 41b08e3454 | |||
| 2fd99694b6 | |||
| 66fad12e63 | |||
| f67c321380 | |||
| cba1aa85d0 | |||
| def4d7b7c7 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal file
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal file
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal file
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal 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);
|
||||
}
|
||||
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal file
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal file
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal 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 <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 <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
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal file
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal file
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal 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;
|
||||
}
|
||||
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal file
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal 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;
|
||||
Reference in New Issue
Block a user