Compare commits

..

42 Commits

Author SHA1 Message Date
612022862a Update assembly and file version to 1.1.0.1
Updated `<FileVersion>` and `<AssemblyVersion>` in the
`ECMJobRunner.WebCron.csproj` file from `1.1.0.0` to `1.1.0.1`.
This reflects a minor revision, likely for a small update or bug fix.
2026-08-05 09:35:09 +02:00
94c2495515 Add IIS publish profile for .NET 8.0 web application
Added a new `IISProfile.pubxml` configuration file to enable
publishing the web application as a package. Configured the
build to use the `Release` configuration and `Any CPU`
platform. Set the target framework to `.NET 8.0` and defined
the IIS deployment path as `JobRunner.WebCron`. The package
is created as a single file and includes the `App_Data`
folder. Specified the desktop build package location and
added a unique project GUID for identification.
2026-08-05 09:30:20 +02:00
d7c8607fbf Fix conditional logic and update exception message
Updated the `else if` condition to use a logical AND (`&&`)
instead of a logical OR (`||`) to ensure the condition checks
if `result.ReturnValue` is both not null and not equal to 0.

Aligned the exception message with the updated logic to
indicate that the expected value is "null or 0" instead of
just "null." This change improves correctness and prevents
unintended behavior.
2026-08-05 09:27:48 +02:00
8d4c08fbec Bump version to 1.1.0 in project file
Updated versioning details in `ECMJobRunner.WebCron.csproj`:
- `<Version>` updated from `1.0.0` to `1.1.0`.
- `<FileVersion>` updated from `1.0.0.0` to `1.1.0.0`.
- `<AssemblyVersion>` updated from `1.0.0.0` to `1.1.0.0`.
- `<InformationalVersion>` updated from `1.0.0` to `1.1.0`.
2026-08-04 20:37:23 +02:00
bc9f234810 Add global exception handling middleware
Introduced `ExceptionHandlingMiddleware` to handle exceptions
globally in the application. The middleware captures exceptions
thrown during the request pipeline, logs them, and returns
appropriate HTTP responses in JSON format.

Key features:
- Handles `JobException` with a 400 Bad Request response.
- Handles unhandled exceptions with a 500 Internal Server Error.
- Logs warnings for `JobException` and errors for unhandled
  exceptions.
- Sets response `ContentType` to `application/json` and writes
  error details as JSON.

Added necessary `using` directives for required namespaces.
2026-08-04 20:37:11 +02:00
545648fe87 Update condition to handle non-zero ReturnValue cases
Modified the `else if` condition in `MainQueryExecutionBehavior.cs` to check if `result.ReturnValue` is either not null or not equal to 0. This ensures that the `JobSqlException` is thrown for additional scenarios where `result.ReturnValue` is non-zero, improving error handling and robustness.
2026-08-04 20:36:56 +02:00
dd9e6a710b feat(history): add ProfileHistory creation with job execution results
TriggeringProfileJobCommand:
- Add RecActionResult property to store ReC action execution results
- Convert handler to primary constructor with ISender injection
- Create ProfileHistory after successful job execution
- Include detailed execution metrics in history (TotalActionCount, ActionExceptionCount, BatchId)
- Add required using statements for ProfileHistories and ValueObjects

ProfileWorkerOptionsValidator:
- Fix XML documentation reference to ValidateOptionsResult.Fail(string)
2026-08-04 16:34:30 +02:00
5b67035a07 refactor(time): change DateTime from UTC to local time
- Change HealthCheckHtmlGenerator to use DateTime.Now instead of DateTime.UtcNow
- Change ProfileWorker health check timestamps to use DateTime.Now
- Change Program health check endpoint to use DateTime.Now
- Ensures consistent local time usage across the application
2026-08-04 16:34:16 +02:00
5b865c0442 fix(sql): change query execution to use ToList before FirstOrDefault
- Change from FirstOrDefaultAsync to ToListAsync + FirstOrDefault
- Ensures proper query execution for both EF6 (.NET Framework 4.8) and EF Core (.NET 8.0)
- Prevents potential query execution issues
- Add using System.Linq directive
2026-08-04 16:34:04 +02:00
111281ac08 feat(logging): improve exception handling and add comprehensive logging
JobExceptionHandlingBehavior:
- Add ILogger for diagnostic output
- Change ResultText to include full exception details (ToString())
- Log JobException with warning level including ProfileId, JobName, ProcessName, BatchId
- Return default instead of re-throwing to allow graceful handling

ReCRequestExecutionBehavior:
- Convert to primary constructor pattern
- Add ILogger for request tracking
- Store RecActionResult in command for later use
- Log successful ReC requests with detailed metrics (TotalActionCount, ActionExceptionCount)
- Improve error handling and logging
2026-08-04 16:33:53 +02:00
2067ffdf2e chore(deps): downgrade ReC.Client from 2.0.0-beta to 1.0.0
- Downgrade ReC.Client to stable version 1.0.0 in Application project
- Downgrade ReC.Client to stable version 1.0.0 in Tests project
2026-08-04 16:33:38 +02:00
f75524f85d feat(config): add DexJobOptions configuration system and update placeholder pattern
- Add SectionName constant to DexJobOptions
- Update placeholder pattern to {#INT#BATCH_ID}
- Add DexJob configuration section to appsettings.json
- Add IConfiguration parameter to DependencyInjection for options binding
- Add Microsoft.Extensions.Options.ConfigurationExtensions package for .NET Framework 4.8
- Improve code documentation and move class into namespace
2026-08-04 16:33:26 +02:00
73db8fbd27 Add JobExceptionHandlingBehavior and improve mappings
Introduced `JobExceptionHandlingBehavior` to handle exceptions, log errors, and rethrow them during MediatR pipeline execution. Updated `DependencyInjection.cs` to register the new behavior and added a `recClientApiUrl` parameter for API configuration.

Enhanced `ProfileMappingProfile.cs` and `GetProfileQuery.cs` with XML documentation for better readability. Improved case-insensitive filtering in `GetProfileQuery` with conditional compilation for .NET version compatibility.

Modified `CreateProfileHistoryCommand.cs` to use a non-nullable `AddedWho` property. Added missing `using` directive in `GetProfileQuery.cs` for compatibility. These changes improve code quality, maintainability, and functionality.
2026-08-03 13:37:01 +02:00
90916f6d03 Refactor exceptions to include profileId context
Refactored `JobException` and derived classes (`InactiveProfileException`, `JobHttpException`, `JobSqlException`) to include `profileId` as a required parameter in their constructors. This ensures consistent context for exceptions related to job execution.

Updated behaviors (`CheckQueryExecutionBehavior`, `MainQueryExecutionBehavior`, `ReCRequestExecutionBehavior`) to use the new constructors, passing `profileId` where applicable. Improved exception messages for better debugging context.

Simplified property initialization and enhanced XML documentation for clarity.
2026-08-03 13:36:42 +02:00
eb060aa54e Add CreateProfileHistoryCommand and mapping profiles
Introduce `CreateProfileHistoryCommand` to handle the creation of profile execution history records, including properties for `ProfileId`, `Result`, `ResultText`, and `AddedWho`.

Add `CreateProfileHistoryCommandHandler` to process the command and persist the data using `IProfileHistoryRepository`.

Define AutoMapper mappings in `MappingProfiles` to map `CreateProfileHistoryCommand` to the `ProfileHistory` entity, with specific configurations for ignored and mapped properties.
2026-08-03 12:08:54 +02:00
45a7086c9a Refactor repository pattern and modernize codebase
Removed `SaveChangesAsync` from `IRepository` and `Repository` to centralize transaction management in `DbContext`. Updated `CfgProfileRepository` to use `Context` property instead of `_context`. Refactored `Repository` class to use C# 9.0 record-like constructor syntax and replaced private fields with properties (`Context`, `DbSet`, `Mapper`).

Replaced `Any()` with `Count == 0` for null checks and updated `FindAsync` to use C# 11 object array syntax. Added conditional compilation for framework-specific differences. These changes improve readability, consistency, and leverage modern C# features.
2026-08-03 12:07:09 +02:00
1110728741 Refactor DEXJob to ProfileJob across the codebase
Renamed namespaces, classes, and commands from `DEXJob` to `ProfileJob` to align with the new "Profiles" context. Updated pipeline behaviors (`CheckQueryExecutionBehavior`, `MainQueryExecutionBehavior`, `ReCRequestExecutionBehavior`) to handle `TriggeringProfileJobCommand`.

Refactored unit tests to reflect the new naming convention, including mock setups and assertions. Updated `DtoExtensions` to return `TriggeringProfileJobBatchCommand`. Adjusted queries and dependency injection to use the new `Profiles` namespace.

Performed general refactoring to replace all references to "DEXJob" with "ProfileJob" in method names, variables, and documentation for consistency and clarity.
2026-08-03 11:36:45 +02:00
a698f8daae Refactor namespaces for DEX job behaviors
Updated namespaces for `CheckQueryExecutionBehavior`,
`MainQueryExecutionBehavior`, and `ReCRequestExecutionBehavior`
from `ECMJobRunner.Application.Behaviors` to
`ECMJobRunner.Application.DEXJob.Commands.Behaviors` to better
align with the `DEXJob.Commands` context.

Updated `DependencyInjection.cs` and test files to reference the
new namespace. Added `using Microsoft.Extensions.Options;` to
support the Options pattern in the updated files.
2026-08-03 11:17:27 +02:00
afdeb8839c Add solution folders and organize project structure
Added two new solution folders, `src` and `test`, to the solution
file (`ECM.JobRunner.sln`) to improve project organization.
Nested existing projects under these folders:
- Projects `{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}`,
  `{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}`,
  `{D97CD489-10D7-432C-9921-196F2E0505FF}`, and
  `{C60BC965-D293-EA64-B153-1941F0648DF4}` were moved under `src`.
- Project `{F64352B6-32BB-4BDE-90FD-FB77482D44E0}` was moved under `test`.

Introduced a `NestedProjects` section in the solution file to define
the hierarchy. No changes were made to existing project configurations
or build settings.
2026-08-03 11:14:52 +02:00
dc2aaa245f Add ResultType enum and Result property in ProfileHistory
Introduced a new `ResultType` enum to represent job execution
result types, including `Ok`, `Error`, `Warning`, and `Unknown`.
Added a `Result` property in the `ProfileHistory` class as a
wrapper around the `ResultId` property, with logic to map
`ResultId` to `ResultType` values. Updated `ProfileHistory.cs`
to include necessary namespaces.
2026-08-03 11:04:10 +02:00
2d7c54ceae Refactor JobException message handling
Renamed the `Message` method to `CreateMessage` for clarity and updated its usage in the `JobException` constructor. Simplified the error message format by removing visual separator lines, resulting in cleaner and more concise output.
2026-08-03 10:34:35 +02:00
af3c8be133 Add AutoMapper and update database connection string
Updated `DependencyInjection.cs` to register AutoMapper with all profiles from the assembly, improving object mapping setup.

Modified `appsettings.json` to update the connection string for `SDD-VMP04-SQL17`, including a new server name and a more secure password, reflecting a move to a different environment or configuration.
2026-08-03 09:52:13 +02:00
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
45 changed files with 1692 additions and 439 deletions

View File

@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Tests", "ECMJo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7704FD14-0546-4ABA-AA37-5EFA6BD7908D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -43,6 +47,13 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{D97CD489-10D7-432C-9921-196F2E0505FF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{F64352B6-32BB-4BDE-90FD-FB77482D44E0} = {7704FD14-0546-4ABA-AA37-5EFA6BD7908D}
{C60BC965-D293-EA64-B153-1941F0648DF4} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
EndGlobalSection

View File

@@ -6,42 +6,31 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception thrown when attempting to execute a job for an inactive profile
/// Extends JobException with profile-specific context
/// </summary>
public class InactiveProfileException : JobException
/// <remarks>
/// Initializes a new instance of InactiveProfileException
/// </remarks>
/// <param name="profileId">ID of the inactive profile</param>
/// <param name="profileName">Name of the inactive profile (nullable)</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <remarks>
/// Use this exception when:
/// - Attempting to trigger DEX job batch for an inactive profile
/// - Attempting to execute individual jobs from an inactive profile
/// - Profile is deactivated during execution
/// </remarks>
public class InactiveProfileException(long profileId, string? profileName, string batchId) : JobException(
profileId,
jobName: "Profile Execution",
processName: "Profile Active Status Validation",
batchId: batchId,
reason: "The profile is marked as inactive and cannot be executed",
innerException: null,
("Profile ID", profileId.ToString(), false),
("Profile Name", profileName, true))
{
/// <summary>
/// Initializes a new instance of InactiveProfileException
/// </summary>
/// <param name="profileId">ID of the inactive profile</param>
/// <param name="profileName">Name of the inactive profile (nullable)</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <remarks>
/// Use this exception when:
/// - Attempting to trigger DEX job batch for an inactive profile
/// - Attempting to execute individual jobs from an inactive profile
/// - Profile is deactivated during execution
/// </remarks>
public InactiveProfileException(long profileId, string? profileName, string batchId)
: base(
jobName: "Profile Execution",
processName: "Profile Active Status Validation",
batchId: batchId,
reason: "The profile is marked as inactive and cannot be executed",
innerException: null,
("Profile ID", profileId.ToString(), false),
("Profile Name", profileName, true))
{
ProfileId = profileId;
ProfileName = profileName;
}
/// <summary>
/// Gets the ID of the inactive profile
/// </summary>
public long ProfileId { get; }
/// <summary>
/// Gets the name of the inactive profile (nullable)
/// </summary>
public string? ProfileName { get; }
public string? ProfileName { get; } = profileName;
}
}

View File

@@ -7,53 +7,54 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Base exception class for job execution failures
/// Provides a flexible structure for capturing job context and detailed error information
/// </summary>
public class JobException : Exception
{
/// <summary>
/// Initializes a new instance of JobException with detailed context information
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</param>
/// <param name="processName">Name of the process/stage being executed (e.g., "MainQueryExecution", "CheckQueryValidation")</param>
/// <param name="batchId">Unique batch identifier for tracking the execution</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
/// <remarks>
/// The details parameter accepts tuples with:
/// - Name: Display name of the detail
/// - Value: String value of the detail (nullable)
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
/// </remarks>
public JobException(string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
: base(
Message(jobName,
/// <remarks>
/// Initializes a new instance of JobException with detailed context information
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</param>
/// <param name="processName">Name of the process/stage being executed (e.g., "MainQueryExecution", "CheckQueryValidation")</param>
/// <param name="batchId">Unique batch identifier for tracking the execution</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
/// <remarks>
/// The details parameter accepts tuples with:
/// - Name: Display name of the detail
/// - Value: String value of the detail (nullable)
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
/// </remarks>
public class JobException(long profileId, string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
: Exception(
CreateMessage(jobName,
[
("Profile Id", profileId.ToString(), false),
("Job Name", jobName, false),
("Process Name", processName, false),
("Batch Id", batchId, false),
("Reason", reason, true),
..details
]),
innerException)
{
JobName = jobName;
ProcessName = processName;
BatchId = batchId;
}
innerException)
{
/// <summary>
/// Gets the profile identifier associated with the job execution
/// </summary>
public long ProfileId { get; } = profileId;
/// <summary>
/// Gets the name of the job that failed
/// </summary>
public string JobName { get; }
public string JobName { get; } = jobName;
/// <summary>
/// Gets the name of the process/stage that was being executed when the failure occurred
/// </summary>
public string ProcessName { get; }
public string ProcessName { get; } = processName;
/// <summary>
/// Gets the unique batch identifier for tracking the execution
/// </summary>
public string BatchId { get; }
public string BatchId { get; } = batchId;
/// <summary>
/// Generates a formatted error message with job context and details
@@ -65,26 +66,22 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Message format:
/// <code>
/// {jobName} could not be completed.
/// ─────────────────────────────────────────
/// Process Name: {processName}
/// Batch Id: {batchId}
/// {additional details...}
/// ─────────────────────────────────────────
/// </code>
/// Details with IgnoreIfNull=true are omitted when their value is null.
/// </remarks>
internal static string Message(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
internal static string CreateMessage(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
{
var message = new System.Text.StringBuilder();
message.AppendLine($"{jobName} could not be completed.");
message.AppendLine("─────────────────────────────────────────");
foreach (var (name, value, ignoreNullValue) in details)
{
if (ignoreNullValue && value is null)
continue;
message.AppendLine($" {name}: {value}");
}
message.AppendLine("─────────────────────────────────────────");
return message.ToString();
}
}

View File

@@ -6,41 +6,36 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
/// Extends JobException with client library and method context
/// </summary>
public class JobHttpException : JobException
/// <remarks>
/// Initializes a new instance of JobHttpException with HTTP client context
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "ReC Request", "API Call")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="clientLibrary">Name of the HTTP client library used (e.g., "ReC.Client", "HttpClient") (nullable)</param>
/// <param name="clientMethod">Name of the client method that failed (e.g., "ExecuteAsync", "PostAsync") (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <remarks>
/// Use this exception for HTTP-related failures such as:
/// - ReC API request failures
/// - REST API communication errors
/// - HTTP client timeout/network issues
/// - Authentication/authorization failures
/// </remarks>
public class JobHttpException(long profileId, string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException)
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Client Library", clientLibrary, true), ("Client Method", clientMethod, true))
{
/// <summary>
/// Initializes a new instance of JobHttpException with HTTP client context
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "ReC Request", "API Call")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="clientLibrary">Name of the HTTP client library used (e.g., "ReC.Client", "HttpClient") (nullable)</param>
/// <param name="clientMethod">Name of the client method that failed (e.g., "ExecuteAsync", "PostAsync") (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <remarks>
/// Use this exception for HTTP-related failures such as:
/// - ReC API request failures
/// - REST API communication errors
/// - HTTP client timeout/network issues
/// - Authentication/authorization failures
/// </remarks>
public JobHttpException(string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Client Library", clientLibrary, true),
("Client Method", clientMethod, true))
{
ClientLibrary = clientLibrary;
ClientMethod = clientMethod;
}
/// <summary>
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
/// </summary>
public string? ClientLibrary { get; }
public string? ClientLibrary { get; } = clientLibrary;
/// <summary>
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
/// </summary>
public string? ClientMethod { get; }
public string? ClientMethod { get; } = clientMethod;
}
}

View File

@@ -6,33 +6,30 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// Exception for SQL query execution failures
/// Extends JobException with SQL query context for debugging
/// </summary>
public class JobSqlException : JobException
/// <remarks>
/// Initializes a new instance of JobSqlException with SQL query context
/// </remarks>
/// <param name="profileId">Identifier of the profile associated with the job</param>
/// <param name="jobName">Name of the job that failed (e.g., "Main Query Execution", "Check Query")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="query">The SQL query that failed (nullable, for debugging purposes)</param>
/// <param name="innerException">The underlying SQL exception (nullable)</param>
/// <remarks>
/// Use this exception for SQL-related failures such as:
/// - Main query execution errors
/// - Check query validation failures
/// - Database connection issues
/// - SQL syntax errors
/// - Query timeout exceptions
///
/// The Query property can be logged for debugging but should be handled carefully
/// to avoid exposing sensitive data in production logs.
/// </remarks>
public class JobSqlException(long profileId, string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException)
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Query", query, true))
{
/// <summary>
/// Initializes a new instance of JobSqlException with SQL query context
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "Main Query Execution", "Check Query")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="query">The SQL query that failed (nullable, for debugging purposes)</param>
/// <param name="innerException">The underlying SQL exception (nullable)</param>
/// <remarks>
/// Use this exception for SQL-related failures such as:
/// - Main query execution errors
/// - Check query validation failures
/// - Database connection issues
/// - SQL syntax errors
/// - Query timeout exceptions
///
/// The Query property can be logged for debugging but should be handled carefully
/// to avoid exposing sensitive data in production logs.
/// </remarks>
public JobSqlException(string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Query", query, true))
{
Query = query;
}
/// <summary>
/// Gets the SQL query that failed (nullable)
@@ -41,6 +38,6 @@ namespace ECMJobRunner.Application.Common.Exceptions
/// This property is marked as virtual to allow derived classes to customize query handling
/// (e.g., sanitizing sensitive data, truncating long queries)
/// </remarks>
public virtual string? Query { get; }
public virtual string? Query { get; } = query;
}
}

View File

@@ -10,6 +10,9 @@ namespace ECMJobRunner.Application.Common.Mapping
/// </summary>
public class ProfileMappingProfile : Profile
{
/// <summary>
/// Configures AutoMapper mappings for <see cref="ECMJobRunner.Domain.Entities.CfgProfile"/> and <see cref="ECMJobRunner.Domain.Entities.ProfileSqlJob"/> entities
/// </summary>
public ProfileMappingProfile()
{
// CfgProfile -> CfgProfileDto

View File

@@ -1,105 +1,109 @@
using ECMJobRunner.Application.Common.Constants;
using System.Text.RegularExpressions;
namespace ECMJobRunner.Application.Common.Options
namespace ECMJobRunner.Application.Common.Options;
/// <summary>
/// Configuration options for DEX job execution
/// </summary>
public class DexJobOptions
{
/// <summary>
/// Configuration options for DEX job execution
/// The configuration section name used to bind this options class from application settings
/// </summary>
public class DexJobOptions
public const string SectionName = "DexJob";
/// <summary>
/// Error handling options for DEX job operations
/// </summary>
public record DexJobErrorHandlingOptions
{
/// <summary>
/// Error handling options for DEX job operations
/// Error handling options for SQL query execution
/// </summary>
public record DexJobErrorHandlingOptions
public record SqlQueryErrorHandlingOptions
{
/// <summary>
/// Error handling options for SQL query execution
/// Action to take when query execution fails
/// </summary>
public record SqlQueryErrorHandlingOptions
{
/// <summary>
/// Action to take when query execution fails
/// </summary>
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
/// <summary>
/// Action to take when query is null or whitespace
/// </summary>
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
/// <summary>
/// Action to take when query returns unexpected result
/// </summary>
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
}
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
/// <summary>
/// Error handling options for HTTP requests
/// Action to take when query is null or whitespace
/// </summary>
public record HttpRequestErrorHandlingOptions
{
/// <summary>
/// Action to take when HTTP request fails
/// </summary>
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
}
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
/// <summary>
/// Error handling for main SQL query
/// Action to take when query returns unexpected result
/// </summary>
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
/// <summary>
/// Error handling for check SQL query
/// </summary>
public SqlQueryErrorHandlingOptions CheckQuery { get; set; } = new();
/// <summary>
/// Error handling for ReC HTTP request
/// </summary>
public HttpRequestErrorHandlingOptions ReCRequest { get; set; } = new();
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Error handling configuration
/// Error handling options for HTTP requests
/// </summary>
public DexJobErrorHandlingOptions Error { get; set; } = new();
/// <summary>
/// Placeholder configuration for dynamic value replacement
/// </summary>
public record PlaceHolderOptions
public record HttpRequestErrorHandlingOptions
{
/// <summary>
/// Configuration for a single placeholder
/// Action to take when HTTP request fails
/// </summary>
public record PlaceHolder
{
/// <summary>
/// Regex pattern to match the placeholder
/// </summary>
public string Pattern { get; set; } = null!;
/// <summary>
/// Regex options for pattern matching
/// </summary>
public RegexOptions RegexOptions { get; set; } = RegexOptions.IgnoreCase;
}
/// <summary>
/// BatchId placeholder configuration
/// </summary>
public PlaceHolder BatchId { get; set; } = new()
{
Pattern = "#INT#BATCH_ID",
RegexOptions = RegexOptions.IgnoreCase
};
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Placeholder configuration
/// Error handling for main SQL query
/// </summary>
public PlaceHolderOptions Placeholders { get; set; } = new();
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
/// <summary>
/// Error handling for check SQL query
/// </summary>
public SqlQueryErrorHandlingOptions CheckQuery { get; set; } = new();
/// <summary>
/// Error handling for ReC HTTP request
/// </summary>
public HttpRequestErrorHandlingOptions ReCRequest { get; set; } = new();
}
/// <summary>
/// Error handling configuration
/// </summary>
public DexJobErrorHandlingOptions Error { get; set; } = new();
/// <summary>
/// Placeholder configuration for dynamic value replacement
/// </summary>
public record PlaceHolderOptions
{
/// <summary>
/// Configuration for a single placeholder
/// </summary>
public record PlaceHolder
{
/// <summary>
/// Regex pattern to match the placeholder
/// </summary>
public string Pattern { get; set; } = null!;
/// <summary>
/// Regex options for pattern matching
/// </summary>
public RegexOptions RegexOptions { get; set; } = RegexOptions.IgnoreCase;
}
/// <summary>
/// BatchId placeholder configuration
/// </summary>
public PlaceHolder BatchId { get; set; } = new()
{
Pattern = "{#INT#BATCH_ID}",
RegexOptions = RegexOptions.IgnoreCase
};
}
/// <summary>
/// Placeholder configuration
/// </summary>
public PlaceHolderOptions Placeholders { get; set; } = new();
}

View File

@@ -1,5 +1,7 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.Profiles.Commands.Behaviors;
using MediatR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReC.Client;
using System.Reflection;
@@ -16,8 +18,10 @@ namespace ECMJobRunner.Application
/// Registers MediatR, pipeline behaviors, and AutoMapper
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="recClientApiUrl">The base URL for the ReC client API</param>
/// <param name="configuration">The application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl)
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl, IConfiguration configuration)
{
var assembly = Assembly.GetExecutingAssembly();
@@ -27,13 +31,20 @@ namespace ECMJobRunner.Application
#else
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
#endif
// Register AutoMapper with all profiles from this assembly
services.AddAutoMapper(assembly);
services.AddRecClient(recClientApiUrl, opt =>
{
opt.LogSuccessfulRequests = true;
});
// Configure DexJobOptions from appsettings.json
services.Configure<DexJobOptions>(configuration.GetSection(DexJobOptions.SectionName));
// Register pipeline behaviors in execution order
// Order matters: MainQuery -> CheckQuery -> ReCRequest
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(JobExceptionHandlingBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));

View File

@@ -17,7 +17,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
@@ -31,6 +31,8 @@
<!-- MediatR for .NET Framework 4.8 -->
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- Options configuration binding for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">

View File

@@ -0,0 +1,60 @@
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.ProfileHistories.Commands
{
/// <summary>
/// Command to create a new profile execution history record
/// </summary>
public class CreateProfileHistoryCommand : IRequest<Unit>
{
/// <summary>
/// Foreign key to the related profile
/// </summary>
public long ProfileId { get; set; }
/// <summary>
/// Execution result type
/// </summary>
public ResultType Result { get; set; }
/// <summary>
/// Result text/message
/// </summary>
#if NET
public required string ResultText { get; set; }
#else
public string ResultText { get; set; } = null!;
#endif
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[JsonIgnore]
public string AddedWho { get; set; } = null!;
}
/// <summary>
/// Handler for <see cref="CreateProfileHistoryCommand"/>
/// </summary>
/// <remarks>
/// Constructor
/// </remarks>
public class CreateProfileHistoryCommandHandler(IProfileHistoryRepository Repository) : IRequestHandler<CreateProfileHistoryCommand, Unit>
{
/// <summary>
/// Handles the command by persisting a new <see cref="ProfileHistory"/> record
/// </summary>
public async Task<Unit> Handle(CreateProfileHistoryCommand request, CancellationToken cancellationToken)
{
await Repository.AddAsync(request, cancellationToken);
return Unit.Value;
}
}
}

View File

@@ -0,0 +1,28 @@
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.ProfileHistories;
/// <summary>
///
/// </summary>
public class MappingProfiles : AutoMapper.Profile
{
/// <summary>
///
/// </summary>
public MappingProfiles()
{
CreateMap<CreateProfileHistoryCommand, ProfileHistory>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
.ForMember(dest => dest.ChangedWho, opt => opt.Ignore())
.ForMember(dest => dest.ChangedWhen, opt => opt.Ignore())
.ForMember(dest => dest.CfgProfile, opt => opt.Ignore());
}
}

View File

@@ -3,7 +3,7 @@ using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the check SQL query for DEX jobs
@@ -46,7 +46,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await ExecuteCheckQueryAsync(command, cancellationToken);
}
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task ExecuteCheckQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task ExecuteCheckQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
{
@@ -71,6 +71,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (result is null)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -79,6 +80,7 @@ namespace ECMJobRunner.Application.Behaviors
innerException: null);
else if (result.ReturnValue <= 0)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -91,6 +93,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
@@ -103,6 +106,7 @@ namespace ECMJobRunner.Application.Behaviors
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName:"Triggering DEX",
processName:"Check Query",
batchId:command.BatchId,

View File

@@ -0,0 +1,51 @@
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors;
/// <summary>
/// Pipeline behavior that catches <see cref="ECMJobRunner.Application.Common.Exceptions.JobException"/> exceptions,
/// persists a profile history error record and re-throws the exception
/// </summary>
/// <typeparam name="TRequest">The type of the MediatR request</typeparam>
/// <typeparam name="TResponse">The type of the MediatR response</typeparam>
/// <param name="Sender">MediatR sender used to dispatch the <see cref="ECMJobRunner.Application.ProfileHistories.Commands.CreateProfileHistoryCommand"/></param>
/// <param name="Logger">Logger for diagnostic output.</param>
public class JobExceptionHandlingBehavior<TRequest, TResponse>(ISender Sender, ILogger<JobExceptionHandlingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
/// <summary>
/// Handles the pipeline behavior
/// Executes main query if request is TriggeringDEXJobCommand
/// </summary>
#if NET48
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
#else
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
try
{
return await next();
}
catch (JobException ex)
{
var cmd = new CreateProfileHistoryCommand()
{
ProfileId = ex.ProfileId,
Result = ResultType.Ok,
ResultText = ex.ToString(),
AddedWho = "ECMJobRunner"
};
await Sender.Send(cmd, cancellationToken);
Logger.LogWarning(ex, "JobException caught in JobExceptionHandlingBehavior for ProfileId {ProfileId}, JobName {JobName}, ProcessName {ProcessName}, BatchId {BatchId}", ex.ProfileId, ex.JobName, ex.ProcessName, ex.BatchId);
return default!;
}
}
}

View File

@@ -3,7 +3,7 @@ using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the main SQL query for DEX jobs
@@ -46,7 +46,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await ExecuteMainQueryAsync(command, cancellationToken);
}
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task ExecuteMainQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task ExecuteMainQueryAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
{
@@ -71,18 +71,20 @@ namespace ECMJobRunner.Application.Behaviors
{
if (result is null)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: "Main Query returned nothing.",
query: sqlMainQuery,
innerException: null);
else if (result.ReturnValue is not null)
else if (result.ReturnValue is not null && result.ReturnValue != 0)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null.",
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null or 0.",
query: sqlMainQuery, innerException: null);
}
}
@@ -90,6 +92,7 @@ namespace ECMJobRunner.Application.Behaviors
{
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
@@ -101,6 +104,7 @@ namespace ECMJobRunner.Application.Behaviors
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,

View File

@@ -1,8 +1,9 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Client;
using ReC.Client.Api;
@@ -10,7 +11,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
{
/// <summary>
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
@@ -18,22 +19,15 @@ namespace ECMJobRunner.Application.Behaviors
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class ReCRequestExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
/// <remarks>
/// Initializes a new instance of ReCRequestExecutionBehavior
/// </remarks>
/// <param name="ReCClient">ReC client for HTTP requests</param>
/// <param name="options">DEX job configuration options</param>
public class ReCRequestExecutionBehavior<TRequest, TResponse>(ReCClient ReCClient, IOptions<DexJobOptions> options, ILogger<ReCRequestExecutionBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ReCClient _reCClient;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of ReCRequestExecutionBehavior
/// </summary>
/// <param name="reCClient">ReC client for HTTP requests</param>
/// <param name="options">DEX job configuration options</param>
public ReCRequestExecutionBehavior(ReCClient reCClient, IOptions<DexJobOptions> options)
{
_reCClient = reCClient;
_options = options.Value;
}
private readonly DexJobOptions Options = options.Value;
/// <summary>
/// Handles the pipeline behavior
@@ -45,7 +39,7 @@ namespace ECMJobRunner.Application.Behaviors
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
if (request is TriggeringProfileJobCommand command)
{
await SendReCRequestAsync(command, cancellationToken);
}
@@ -53,20 +47,30 @@ namespace ECMJobRunner.Application.Behaviors
return await next();
}
private async Task SendReCRequestAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
private async Task SendReCRequestAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
{
try
{
await _reCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
command.RecActionResult = await ReCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
{
BatchId = command.BatchId,
}, cancel);
Logger.LogInformation(
"ReC request completed successfully. Profile ID: {ProfileId} | Job name: {JobName} | Batch ID: {BatchId} | Total action count: {TotalActionCount} | Action exception count: {ActionExceptionCount}",
command.Job.ProfileId,
command.Job.Name,
command.BatchId,
command.RecActionResult?.TotalActionCount ?? 0,
command.RecActionResult?.ActionExceptionCount ?? 0);
}
catch (Exception ex)
{
if (_options.Error.ReCRequest.OnSending == ErrorAction.Stop)
if (Options.Error.ReCRequest.OnSending == ErrorAction.Stop)
{
throw new JobHttpException(
profileId: command.Job.ProfileId,
jobName: "Triggering DEX",
processName: "ReC Http Request",
batchId: command.BatchId,

View File

@@ -6,13 +6,13 @@ using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
namespace ECMJobRunner.Application.Profiles.Commands
{
/// <summary>
/// Command to trigger DEX job batch for a profile
/// Executes all SQL jobs associated with a profile ID
/// </summary>
public class TriggeringDEXJobBatchCommand : IRequest<Unit>
public class TriggeringProfileJobBatchCommand : IRequest<Unit>
{
/// <summary>
/// Profile ID to trigger all associated SQL jobs
@@ -26,7 +26,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// Validates that the profile is active before execution
/// </summary>
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
: IRequestHandler<TriggeringProfileJobBatchCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobBatchCommand
@@ -36,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
/// <exception cref="InactiveProfileException">Thrown when the profile is not active</exception>
public async Task<Unit> Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken)
public async Task<Unit> Handle(TriggeringProfileJobBatchCommand request, CancellationToken cancellationToken)
{
var batchId = CreateBatchId();
@@ -59,7 +59,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
foreach (var job in jobs)
{
await sender.Send(new TriggeringDEXJobCommand
await sender.Send(new TriggeringProfileJobCommand
{
Job = job,
BatchId = batchId

View File

@@ -1,9 +1,13 @@
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Application.ProfileHistories.Commands;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Domain.ValueObjects;
using MediatR;
using ReC.Client.Api;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
namespace ECMJobRunner.Application.Profiles.Commands
{
/// <summary>
/// Command to trigger a single DEX job execution
@@ -12,7 +16,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
/// </summary>
public class TriggeringDEXJobCommand : IRequest<Unit>
public class TriggeringProfileJobCommand : IRequest<Unit>
{
/// <summary>
/// The SQL job to execute
@@ -23,6 +27,8 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// Unique batch identifier for this execution
/// </summary>
public string BatchId { get; set; } = null!;
internal BatchRecActionViewResponse? RecActionResult { get; set; }
}
/// <summary>
@@ -30,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// All execution logic is delegated to pipeline behaviors
/// This handler simply returns completion after behaviors execute
/// </summary>
public class TriggeringDEXJobCommandHandler : IRequestHandler<TriggeringDEXJobCommand, Unit>
public class TriggeringDEXJobCommandHandler(ISender Sender) : IRequestHandler<TriggeringProfileJobCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobCommand
@@ -39,13 +45,24 @@ namespace ECMJobRunner.Application.DEXJob.Commands
/// <param name="request">The command containing job and batch ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
public Task<Unit> Handle(TriggeringDEXJobCommand request, CancellationToken cancellationToken)
public async Task<Unit> Handle(TriggeringProfileJobCommand request, CancellationToken cancellationToken)
{
// All execution logic is handled by pipeline behaviors:
// - MainQueryExecutionBehavior
// - CheckQueryExecutionBehavior
// - ReCRequestExecutionBehavior
return Task.FromResult(Unit.Value);
var cmd = new CreateProfileHistoryCommand
{
Result = ResultType.Ok,
ResultText = $"Job '{request.Job.Name}' erfolgreich abgeschlossen. | Batch-ID: {request.BatchId} | Verarbeitete Aktionen: {request.RecActionResult?.TotalActionCount ?? 0} | Fehlgeschlagene Aktionen: {request.RecActionResult?.ActionExceptionCount ?? 0}",
ProfileId = request.Job.ProfileId,
AddedWho = "ECMJobRunner"
};
await Sender.Send(cmd, cancellationToken);
return Unit.Value;
}
}
}

View File

@@ -2,12 +2,13 @@ using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Queries
namespace ECMJobRunner.Application.Profiles.Queries
{
/// <summary>
/// Query to retrieve profiles with flexible filtering options
@@ -58,12 +59,23 @@ namespace ECMJobRunner.Application.DEXJob.Queries
private readonly ICfgProfileRepository _profileRepository;
private readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
/// <param name="profileRepository">Repository for profile data access</param>
/// <param name="mapper">AutoMapper instance for entity-to-DTO mapping</param>
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
{
_profileRepository = profileRepository;
_mapper = mapper;
}
/// <summary>
/// Handles the <see cref="GetProfileQuery"/> by retrieving and mapping profiles
/// </summary>
/// <param name="request">The query containing optional filter parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of matched profiles mapped to <see cref="CfgProfileDto"/></returns>
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
{
IEnumerable<Domain.Entities.CfgProfile> profiles;
@@ -112,8 +124,11 @@ namespace ECMJobRunner.Application.DEXJob.Queries
if (!string.IsNullOrWhiteSpace(request.ProfileName))
{
var searchName = request.ProfileName.ToLowerInvariant();
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
#if NET
profiles = profiles.Where(p => p.ProfileName.Contains(request.ProfileName, StringComparison.OrdinalIgnoreCase));
#else
profiles = profiles.Where(p => p.ProfileName.IndexOf(request.ProfileName!, StringComparison.OrdinalIgnoreCase) >= 0);
#endif
}
}

View File

@@ -1,3 +1,4 @@
using ECMJobRunner.Domain.ValueObjects;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -35,6 +36,18 @@ namespace ECMJobRunner.Domain.Entities
[Column("RESULT_ID")]
public byte ResultId { get; set; }
/// <summary>
/// Gets or sets the result type of the job execution.
/// This property is not mapped to a database column; it wraps <see cref="ResultId"/>.
/// If <see cref="ResultId"/> does not correspond to a defined <see cref="ResultType"/> value, returns <see cref="ResultType.Unknown"/>.
/// </summary>
[NotMapped]
public ResultType Result
{
get => Enum.IsDefined(typeof(ResultType), ResultId) ? (ResultType)ResultId : ResultType.Unknown;
set => ResultId = (byte)value;
}
/// <summary>
/// Result text/message
/// </summary>

View File

@@ -90,10 +90,5 @@ namespace ECMJobRunner.Domain.Interfaces
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if entity was found and deleted, false otherwise</returns>
Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Save all changes asynchronously
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,27 @@
namespace ECMJobRunner.Domain.ValueObjects;
/// <summary>
/// Represents the result type of a job execution.
/// </summary>
public enum ResultType : byte
{
/// <summary>
/// The operation completed successfully.
/// </summary>
Ok = 0,
/// <summary>
/// The operation encountered an error.
/// </summary>
Error = 1,
/// <summary>
/// The operation completed with warnings.
/// </summary>
Warning = 2,
/// <summary>
/// The operation result is unknown.
/// </summary>
Unknown = 255
}

View File

@@ -31,7 +31,7 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// </summary>
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
@@ -41,7 +41,7 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// </summary>
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
{
return await _context.CfgProfiles
return await Context.CfgProfiles
.Include(p => p.SqlJobs)
.Where(p => p.Active)
.ToListAsync(cancellationToken);

View File

@@ -20,145 +20,144 @@ namespace ECMJobRunner.Infrastructure.Repositories
/// Uses AutoMapper for DTO mapping
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
/// <remarks>
/// Constructor
/// </remarks>
public class Repository<TEntity>(JobRunnerDbContext context, IMapper mapper) : IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Database context
/// </summary>
protected readonly JobRunnerDbContext _context;
protected readonly JobRunnerDbContext Context = context ?? throw new ArgumentNullException(nameof(context));
/// <summary>
/// DbSet for the entity
/// </summary>
protected readonly DbSet<TEntity> _dbSet;
protected readonly DbSet<TEntity> DbSet = context.Set<TEntity>();
/// <summary>
/// AutoMapper instance for DTO mapping
/// </summary>
protected readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
public Repository(JobRunnerDbContext context, IMapper mapper)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_dbSet = context.Set<TEntity>();
}
protected readonly IMapper Mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
/// <inheritdoc/>
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
#if NET48
return await _dbSet.FindAsync(cancellationToken, id);
return await DbSet.FindAsync(cancellationToken, id);
#else
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
return await DbSet.FindAsync([id], cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.ToListAsync(cancellationToken);
return await DbSet.ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
return await DbSet.Where(predicate).ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
var entity = _mapper.Map<TEntity>(dto);
#if NET48
_dbSet.Add(entity);
await Task.CompletedTask;
#else
await _dbSet.AddAsync(entity, cancellationToken);
#endif
await SaveChangesAsync(cancellationToken);
var entity = Mapper.Map<TEntity>(dto);
#if NET48
DbSet.Add(entity);
#else
await DbSet.AddAsync(entity, cancellationToken);
#endif
await Context.SaveChangesAsync(cancellationToken);
return entity;
}
/// <inheritdoc/>
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
var dtoList = dtos.ToList();
if (!dtoList.Any())
return 0;
var entities = _mapper.Map<List<TEntity>>(dtoList);
#if NET48
_dbSet.AddRange(entities);
await Task.CompletedTask;
#else
await _dbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await SaveChangesAsync(cancellationToken);
var dtoList = dtos.ToList();
if (dtoList.Count == 0)
return 0;
var entities = Mapper.Map<List<TEntity>>(dtoList);
#if NET48
DbSet.AddRange(entities);
#else
await DbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
#endif
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
if (entities.Count == 0)
return 0;
foreach (var entity in entities)
{
_mapper.Map(dto, entity);
Mapper.Map(dto, entity);
}
return await SaveChangesAsync(cancellationToken);
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
#if NETFRAMEWORK
if (dto == null) throw new ArgumentNullException(nameof(dto));
#endif
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
_mapper.Map(dto, entity);
Mapper.Map(dto, entity);
await SaveChangesAsync(cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
if (entities.Count == 0)
return 0;
_dbSet.RemoveRange(entities);
DbSet.RemoveRange(entities);
return await SaveChangesAsync(cancellationToken);
return await Context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -169,20 +168,10 @@ namespace ECMJobRunner.Infrastructure.Repositories
if (entity == null)
return false;
_dbSet.Remove(entity);
DbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
#if NET48
return await _context.SaveChangesAsync();
#else
return await _context.SaveChangesAsync(cancellationToken);
#endif
}
}
}

View File

@@ -1,6 +1,5 @@
#if NET48
using System.Data.Entity;
using System.Linq;
#else
using Microsoft.EntityFrameworkCore;
#endif
@@ -8,6 +7,7 @@ using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
namespace ECMJobRunner.Infrastructure.Services
{
@@ -41,16 +41,16 @@ namespace ECMJobRunner.Infrastructure.Services
// Entity Framework 6 implementation
var result = await _context.Database
.SqlQuery<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
return result;
.ToListAsync(cancellationToken);
return result.FirstOrDefault();
#else
// Entity Framework Core implementation
var result = await _context.Database
.SqlQueryRaw<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
.ToListAsync(cancellationToken);
return result;
return result.FirstOrDefault();
#endif
}
}

View File

@@ -1,10 +1,10 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public CheckQueryExecutionBehaviorTests()
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -68,7 +68,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -96,7 +96,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
@@ -122,7 +122,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
@@ -157,8 +157,8 @@ namespace ECMJobRunner.Tests.Application
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
var behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
@@ -181,7 +181,7 @@ namespace ECMJobRunner.Tests.Application
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" },
BatchId = batchId
@@ -209,7 +209,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullResult_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"

View File

@@ -1,10 +1,10 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public MainQueryExecutionBehaviorTests()
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -65,7 +65,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullReturnValue_DoesNotThrow()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -90,7 +90,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
@@ -116,7 +116,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
@@ -151,8 +151,8 @@ namespace ECMJobRunner.Tests.Application
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
var behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
@@ -175,7 +175,7 @@ namespace ECMJobRunner.Tests.Application
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
BatchId = batchId
@@ -203,7 +203,7 @@ namespace ECMJobRunner.Tests.Application
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
var command = new TriggeringProfileJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
BatchId = "20260711143025123456"

View File

@@ -46,7 +46,7 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(jobs);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Tests.Application
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Exactly(2),
"Should send command for each job");
}
@@ -68,7 +68,7 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
@@ -76,7 +76,7 @@ namespace ECMJobRunner.Tests.Application
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
Times.Never,
"Should not send any commands when no jobs found");
}
@@ -115,13 +115,13 @@ namespace ECMJobRunner.Tests.Application
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { job });
TriggeringDEXJobCommand? capturedCommand = null;
TriggeringProfileJobCommand? capturedCommand = null;
_mockSender
.Setup(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringDEXJobCommand)
.Setup(s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringProfileJobCommand)
.ReturnsAsync(Unit.Value);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
// Act
await _handler.Handle(command, CancellationToken.None);

View File

@@ -51,7 +51,7 @@
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
<PackageReference Include="ReC.Client" Version="1.0.0" />
</ItemGroup>
<!-- .NET 8.0 specific packages -->

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.1.0</Version>
<FileVersion>1.1.0.1</FileVersion>
<AssemblyVersion>1.1.0.1</AssemblyVersion>
<InformationalVersion>1.1.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

@@ -1,19 +1,36 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.Profiles.Commands;
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()}";
}
public static TriggeringDEXJobBatchCommand ToJob(this CfgProfileDto profile)
/// <summary>
/// Converts a profile DTO to a DEX job batch command.
/// </summary>
/// <param name="profile">The profile configuration DTO.</param>
/// <returns>A <see cref="TriggeringProfileJobBatchCommand"/> ready to execute the profile job.</returns>
public static TriggeringProfileJobBatchCommand ToJob(this CfgProfileDto profile)
{
return new TriggeringDEXJobBatchCommand
return new TriggeringProfileJobBatchCommand
{
ProfileId = profile.Id,
};

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.Now: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

@@ -0,0 +1,74 @@
using ECMJobRunner.Application.Common.Exceptions;
using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace ECMJobRunner.WebCron.Middleware;
/// <summary>
/// Middleware for handling exceptions globally in the application.
/// Captures exceptions thrown during the request pipeline execution,
/// logs them, and returns an appropriate HTTP response with a JSON error details.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
/// </remarks>
/// <param name="Next">The next middleware in the request pipeline.</param>
/// <param name="Logger">The logger instance for logging exceptions.</param>
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
{
/// <summary>
/// Invokes the middleware to handle the HTTP request.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await Next(context); // Continue down the pipeline
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, Logger);
}
}
/// <summary>
/// Handles exceptions by logging them and writing an appropriate JSON response.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="logger">The logger instance for logging the exception.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
{
context.Response.ContentType = "application/json";
ValidationProblemDetails details;
switch (exception)
{
case JobException jobEx:
logger.LogWarning(jobEx, "Job exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
details = new()
{
Title = "Job Exception",
Detail = jobEx.Message
};
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
details = new()
{
Title = "Internal Server Error",
Detail = "An unexpected error occurred. Please try again later."
};
break;
}
if (details is not null)
await context.Response.WriteAsJsonAsync(details);
}
}

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(string)"/> 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.Profiles.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.Now;
_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.Now - _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,13 @@
using ECMJobRunner.Application;
using ECMJobRunner.Infrastructure;
using ECMJobRunner.WebCron;
using ECMJobRunner.WebCron.HealthCheck;
using ECMJobRunner.WebCron.Middleware;
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 +23,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 +57,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")
@@ -62,7 +66,7 @@ builder.Services.AddJobRunnerInfrastructure(cnnStr);
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
builder.Services.AddJobRunnerServices(recClientApiUrl);
builder.Services.AddJobRunnerServices(recClientApiUrl, builder.Configuration);
// Get Hangfire storage configuration
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
@@ -100,9 +104,13 @@ builder.Services.AddHangfireServer();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add health checks
builder.Services.AddHealthChecks()
.AddCheck<ProfileWorker>("profile-worker", tags: ["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 =>
@@ -116,6 +124,8 @@ builder.Services.AddSerilogUi(logUIOpt =>
var app = builder.Build();
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
@@ -125,12 +135,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 +164,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.Now,
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

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\JobRunner\PreRelease\WebCron\net8\$(Version)\JobRunner.WebCron.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>JobRunner.WebCron</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

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,11 +3,10 @@
"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;"
"SDD-VMP04-SQL17": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;"
},
"AllowedHosts": "*",
"HostingOptions": {
@@ -18,5 +17,34 @@
},
"ReC": {
"ApiUrl": "http://172.24.12.39:90"
},
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
},
"ProfileWorker": {
"IntervalMS": 60000
},
"DexJob": {
"Error": {
"MainQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Ignore",
"OnUnexpectedResult": "Stop"
},
"CheckQuery": {
"OnExecution": "Stop",
"IfNullOrWhiteSpace": "Ignore",
"OnUnexpectedResult": "Stop"
},
"ReCRequest": {
"OnSending": "Stop"
}
},
"Placeholders": {
"BatchId": {
"Pattern": "{#INT#BATCH_ID}",
"RegexOptions": "IgnoreCase"
}
}
}
}

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;