feat: Add Hangfire cronjob system with web dashboard, Windows Service support, and Serilog file/console logging

- Add Hangfire packages (AspNetCore, Core, InMemory, SqlServer) with configurable storage (InMemory vs SQL Server)
- Configure Hangfire dashboard at /hangfire with AllowAllDashboardAuthorizationFilter (no auth for development)
- Add Microsoft.Extensions.Hosting.WindowsServices package with conditional UseWindowsService() based on HostingOptions:UseWindowsService config
- Create ProfileManager BackgroundService with IServiceScopeFactory for scoped service resolution per iteration
- Create AllowAllDashboardAuthorizationFilter for Hangfire dashboard access
- Create DtoExtensions with JobId() and ToJob() helper methods
- Configure Serilog with file sink (Production: Logs/log-.txt, daily rolling, 30 day retention) and console sink (Development)
- Add Serilog enrichers: FromLogContext, WithMachineName, WithThreadId
- Update appsettings.json with Hangfire:InMemory flag, HostingOptions:UseWindowsService flag, and Serilog configuration
- Create appsettings.Development.json with console-specific Serilog configuration
This commit is contained in:
2026-07-12 04:49:22 +02:00
parent d50232b0bb
commit 8b0f97c330
5 changed files with 122 additions and 49 deletions

View File

@@ -13,6 +13,9 @@
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>

View File

@@ -14,49 +14,57 @@ namespace ECMJobRunner.WebCron
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
try
{
if (Logger.IsEnabled(LogLevel.Information))
while (!stoppingToken.IsCancellationRequested)
{
Logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
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);
}
// 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

@@ -4,14 +4,30 @@ using ECMJobRunner.WebCron;
using Hangfire;
using Hangfire.SqlServer;
using Microsoft.Data.SqlClient;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog from appsettings.json
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
.Build())
.CreateLogger();
// Configure Windows Service hosting if enabled
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
try
{
builder.Host.UseWindowsService();
}
Log.Information("Starting ECMJobRunner.WebCron application");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog for logging
builder.Host.UseSerilog();
// Configure Windows Service hosting if enabled
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
{
builder.Host.UseWindowsService();
}
// Add services to the container.
builder.Services.AddControllers();
@@ -84,3 +100,12 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -1,8 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Hangfire": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
}
}

View File

@@ -1,4 +1,27 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Hangfire": "Information"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "Logs/log-.txt",
"rollingInterval": "Day",
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}",
"retainedFileCountLimit": 30
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"Logging": {
"LogLevel": {
"Default": "Information",