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:
@@ -13,6 +13,9 @@
|
|||||||
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
|
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
|
<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" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -14,49 +14,57 @@ namespace ECMJobRunner.WebCron
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
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();
|
catch (Exception ex)
|
||||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
{
|
||||||
|
Logger.LogError(ex, "An error occurred in ProfileManager.");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,30 @@ using ECMJobRunner.WebCron;
|
|||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.SqlServer;
|
using Hangfire.SqlServer;
|
||||||
using Microsoft.Data.SqlClient;
|
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
|
try
|
||||||
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
|
|
||||||
{
|
{
|
||||||
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.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
@@ -84,3 +100,12 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [ "Serilog.Sinks.Console" ],
|
||||||
"Default": "Information",
|
"MinimumLevel": {
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"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" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
Reference in New Issue
Block a user