Compare commits
42 Commits
d61d14145a
...
feat/dex-j
| Author | SHA1 | Date | |
|---|---|---|---|
| 612022862a | |||
| 94c2495515 | |||
| d7c8607fbf | |||
| 8d4c08fbec | |||
| bc9f234810 | |||
| 545648fe87 | |||
| dd9e6a710b | |||
| 5b67035a07 | |||
| 5b865c0442 | |||
| 111281ac08 | |||
| 2067ffdf2e | |||
| f75524f85d | |||
| 73db8fbd27 | |||
| 90916f6d03 | |||
| eb060aa54e | |||
| 45a7086c9a | |||
| 1110728741 | |||
| a698f8daae | |||
| afdeb8839c | |||
| dc2aaa245f | |||
| 2d7c54ceae | |||
| af3c8be133 | |||
| 8327c0fd0b | |||
| 98f8dc8710 | |||
| 5f251c1209 | |||
| 9537954626 | |||
| 275b06fedb | |||
| bc881bf70f | |||
| 84b95c53e4 | |||
| 511fb159c4 | |||
| 4af0ed7d14 | |||
| 0fd6a97968 | |||
| cec9cfe2ed | |||
| eb2514f1fc | |||
| 8a05d86285 | |||
| b48e3d1823 | |||
| 41b08e3454 | |||
| 2fd99694b6 | |||
| 66fad12e63 | |||
| f67c321380 | |||
| cba1aa85d0 | |||
| def4d7b7c7 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<,>));
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
ECMJobRunner.Application/ProfileHistories/MappingProfiles.cs
Normal file
28
ECMJobRunner.Application/ProfileHistories/MappingProfiles.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
27
ECMJobRunner.Domain/ValueObjects/ResultType.cs
Normal file
27
ECMJobRunner.Domain/ValueObjects/ResultType.cs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -8,6 +8,11 @@ namespace ECMJobRunner.WebCron
|
||||
/// </summary>
|
||||
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the current user is authorized to access the Hangfire Dashboard.
|
||||
/// </summary>
|
||||
/// <param name="context">The Hangfire dashboard context containing request information.</param>
|
||||
/// <returns>Always returns <c>true</c> to allow all users (development only).</returns>
|
||||
public bool Authorize(DashboardContext context)
|
||||
{
|
||||
// Allow all users - FOR DEVELOPMENT ONLY
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>ECMJobRunner.WebCron</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>ECMJobRunner.WebCron</Product>
|
||||
<Version>1.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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal file
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Text;
|
||||
|
||||
namespace ECMJobRunner.WebCron.HealthCheck;
|
||||
|
||||
/// <summary>
|
||||
/// Generates HTML representation of ASP.NET Core health check reports.
|
||||
/// Produces a Bootstrap 5-based UI with auto-refresh, status indicators, and detailed metrics.
|
||||
/// </summary>
|
||||
public static class HealthCheckHtmlGenerator
|
||||
{
|
||||
private static readonly string CacheId = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a complete HTML page for displaying health check results.
|
||||
/// </summary>
|
||||
/// <param name="report">The health check report containing service status information.</param>
|
||||
/// <returns>A complete HTML document string ready to be sent as HTTP response.</returns>
|
||||
/// <remarks>
|
||||
/// The generated UI includes:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Navigation links to Hangfire, Serilog.UI, and health endpoints</description></item>
|
||||
/// <item><description>Overall health status with color-coded badge</description></item>
|
||||
/// <item><description>Summary card with total duration and check count</description></item>
|
||||
/// <item><description>Individual check cards with detailed metrics and exception info</description></item>
|
||||
/// <item><description>Auto-refresh countdown (10 seconds)</description></item>
|
||||
/// </list>
|
||||
/// External dependencies:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Bootstrap 5.3.0 (CDN)</description></item>
|
||||
/// <item><description>/css/health-ui.css (custom styles)</description></item>
|
||||
/// <item><description>/js/health-ui.js (auto-refresh logic)</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static string Generate(HealthReport report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine(@$"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""UTF-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Health Check - ECMJobRunner</title>
|
||||
<link rel=""stylesheet"" href=""https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"" />
|
||||
<link rel=""stylesheet"" href=""/css/health-ui.css?cache={CacheId}"" />
|
||||
<script src=""/js/health-ui.js?cache={CacheId}""></script>
|
||||
</head>
|
||||
<body>");
|
||||
|
||||
// Navigation
|
||||
AppendNavigation(sb);
|
||||
|
||||
// Overall Status Header
|
||||
AppendOverallStatus(sb, report);
|
||||
|
||||
// Summary Card
|
||||
AppendSummaryCard(sb, report);
|
||||
|
||||
// Individual Checks
|
||||
AppendIndividualChecks(sb, report);
|
||||
|
||||
// Refresh Info
|
||||
AppendRefreshInfo(sb);
|
||||
|
||||
sb.AppendLine(@"
|
||||
</body>
|
||||
</html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the navigation menu with links to application dashboards.
|
||||
/// </summary>
|
||||
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||
private static void AppendNavigation(StringBuilder sb)
|
||||
{
|
||||
sb.Append(@"
|
||||
<div class=""container-fluid"">
|
||||
<div class=""nav-links"">");
|
||||
|
||||
// Append emojis separately (not in verbatim string)
|
||||
sb.AppendLine();
|
||||
sb.Append(" <a href=\"/\">🏠 Home</a>");
|
||||
sb.AppendLine();
|
||||
sb.Append(" <a href=\"/hangfire\">🔧 Hangfire Dashboard</a>");
|
||||
sb.AppendLine();
|
||||
sb.Append(" <a href=\"/serilog-ui\">📋 Logs (Serilog.UI)</a>");
|
||||
sb.AppendLine();
|
||||
sb.Append(" <a href=\"/health\">📊 Health API</a>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends the overall status header showing aggregate health and timestamp.
|
||||
/// </summary>
|
||||
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||
/// <param name="report">The health check report.</param>
|
||||
private static void AppendOverallStatus(StringBuilder sb, HealthReport report)
|
||||
{
|
||||
var statusClass = GetStatusClass(report.Status);
|
||||
var badgeClass = GetBadgeClass(report.Status);
|
||||
var icon = GetStatusIcon(report.Status);
|
||||
|
||||
sb.Append($@"
|
||||
<div class=""header-card"">
|
||||
<div class=""d-flex justify-content-between align-items-center"">
|
||||
<div>
|
||||
<h1 class=""mb-2"">");
|
||||
sb.Append(icon);
|
||||
sb.Append($@" Health Check Status</h1>
|
||||
<p class=""text-muted mb-0"">Last checked: {DateTime.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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using ECMJobRunner.Application.DEXJob.Queries;
|
||||
using ECMJobRunner.WebCron.Extensions;
|
||||
using Hangfire;
|
||||
using MediatR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ECMJobRunner.WebCron
|
||||
{
|
||||
public class ProfileManager(ILogger<ProfileManager> Logger, IServiceScopeFactory ScopeFactory, IRecurringJobManager JobManager) : BackgroundService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CfgProfileDto> Profiles = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
Logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
var profiles = await mediator.Send(new GetProfileQuery()
|
||||
{
|
||||
Active = true,
|
||||
IncludeSqlJobs = true
|
||||
}, stoppingToken);
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
if (Profiles.TryGetValue(profile.JobId(), out var currentProfile)
|
||||
&& currentProfile.Schedule == profile.Schedule)
|
||||
continue;
|
||||
|
||||
// Add or update recurring job using MediatR command
|
||||
JobManager.AddOrUpdate<IMediator>(
|
||||
profile.JobId(),
|
||||
mediator => mediator.Send(profile.ToJob(), CancellationToken.None),
|
||||
profile.Schedule,
|
||||
new RecurringJobOptions
|
||||
{
|
||||
TimeZone = TimeZoneInfo.Local
|
||||
}
|
||||
);
|
||||
|
||||
// Store/update in local cache
|
||||
Profiles[profile.JobId()] = profile;
|
||||
|
||||
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
|
||||
profile.JobId(), profile.Schedule);
|
||||
}
|
||||
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "An error occurred in ProfileManager.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal file
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring ProfileWorker services in the DI container.
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers ProfileWorker background service and related dependencies.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configuration">Application configuration containing ProfileWorker settings.</param>
|
||||
/// <returns>The configured service collection for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Registers the following services:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="ProfileWorkerOptions"/> - Configuration options from appsettings.json</description></item>
|
||||
/// <item><description><see cref="ProfileWorker"/> - Singleton background service (also registered as IHostedService)</description></item>
|
||||
/// <item><description><see cref="ProfileCache"/> - Singleton cache for profile state</description></item>
|
||||
/// <item><description><see cref="ProfileWork"/> - Scoped service for profile synchronization logic</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddProfileWorker(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// Configure ProfileWorker options from appsettings.json
|
||||
services.Configure<ProfileWorkerOptions>(
|
||||
configuration.GetSection(ProfileWorkerOptions.SectionName));
|
||||
|
||||
// Validate options at startup
|
||||
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
|
||||
|
||||
// Register ProfileWorker as both HostedService and singleton (for health check access)
|
||||
services.AddSingleton<ProfileWorker>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<ProfileWorker>());
|
||||
|
||||
services.AddSingleton<ProfileCache>();
|
||||
services.AddScoped<ProfileWork>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="ProfileWorkerOptions"/> configuration at application startup.
|
||||
/// Ensures IntervalMS is within acceptable bounds to prevent misconfiguration.
|
||||
/// </summary>
|
||||
internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOptions>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the ProfileWorker options.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the options instance (not used).</param>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <returns>
|
||||
/// <see cref="ValidateOptionsResult.Success"/> if valid,
|
||||
/// or <see cref="ValidateOptionsResult.Fail(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;
|
||||
}
|
||||
}
|
||||
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal file
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe cache for storing active profile configurations.
|
||||
/// Uses <see cref="ConcurrentDictionary{TKey, TValue}"/> to track profile state
|
||||
/// and detect changes in schedule or removal.
|
||||
/// </summary>
|
||||
public class ProfileCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CfgProfileDto> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a profile from the cache by job identifier.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The unique job identifier.</param>
|
||||
/// <returns>The cached profile, or <c>null</c> if not found.</returns>
|
||||
public CfgProfileDto? Get(string jobId) => _cache.TryGetValue(jobId, out var profile) ? profile : null;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new profile or updates an existing profile in the cache.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The unique job identifier.</param>
|
||||
/// <param name="profile">The profile configuration to cache.</param>
|
||||
public void AddOrUpdate(string jobId, CfgProfileDto profile) => _cache[jobId] = profile;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to remove a profile from the cache.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The unique job identifier.</param>
|
||||
/// <param name="profile">The removed profile, or <c>null</c> if not found.</param>
|
||||
/// <returns><c>true</c> if the profile was removed; otherwise, <c>false</c>.</returns>
|
||||
public bool TryRemove(string jobId, out CfgProfileDto? profile) => _cache.TryRemove(jobId, out profile);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all job identifiers currently stored in the cache.
|
||||
/// </summary>
|
||||
/// <returns>A collection of job identifiers.</returns>
|
||||
public IEnumerable<string> GetAllJobIds() => _cache.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a profile from the cache.
|
||||
/// </summary>
|
||||
/// <param name="jobId">The unique job identifier.</param>
|
||||
/// <param name="profile">The cached profile, or <c>null</c> if not found.</param>
|
||||
/// <returns><c>true</c> if the profile was found; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetValue(string jobId, out CfgProfileDto? profile) => _cache.TryGetValue(jobId, out profile);
|
||||
}
|
||||
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal file
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using ECMJobRunner.Application.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal file
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that continuously synchronizes active profiles with Hangfire recurring jobs.
|
||||
/// Implements health check monitoring to track service status and failure conditions.
|
||||
/// </summary>
|
||||
/// <param name="Logger">Logger for diagnostic output.</param>
|
||||
/// <param name="ScopeFactory">Factory for creating service scopes (required for scoped service resolution).</param>
|
||||
/// <param name="Options">Configuration options for interval timing.</param>
|
||||
/// <remarks>
|
||||
/// This service runs on a configurable interval (default 1 second) and performs the following:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Fetches active profiles from the database</description></item>
|
||||
/// <item><description>Synchronizes profiles with Hangfire recurring jobs</description></item>
|
||||
/// <item><description>Tracks health status based on success/failure patterns</description></item>
|
||||
/// <item><description>Gracefully handles cancellation during application shutdown</description></item>
|
||||
/// </list>
|
||||
/// Health states (with adaptive thresholds):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Degraded (Initializing)</b>: Service starting up, waiting for first successful run</description></item>
|
||||
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
|
||||
/// <item><description><b>Degraded</b>: 1+ consecutive failures but within time threshold</description></item>
|
||||
/// <item><description><b>Unhealthy</b>: No success within adaptive threshold (3x interval for fast intervals <10s, 1.5x for slower intervals)</description></item>
|
||||
/// </list>
|
||||
/// The adaptive multiplier ensures fast problem detection when using longer intervals (e.g., 60s interval = 90s timeout)
|
||||
/// while maintaining tolerance for network jitter on short intervals (e.g., 1s interval = 3s timeout).
|
||||
/// </remarks>
|
||||
public class ProfileWorker(
|
||||
ILogger<ProfileWorker> Logger,
|
||||
IServiceScopeFactory ScopeFactory,
|
||||
IOptions<ProfileWorkerOptions> Options) : BackgroundService, IHealthCheck
|
||||
{
|
||||
private readonly ProfileWorkerOptions _options = Options.Value;
|
||||
|
||||
// Health check state
|
||||
private DateTime? _lastSuccessfulRun = null; // null = not run yet
|
||||
private int _consecutiveFailures = 0;
|
||||
private Exception? _lastException = null;
|
||||
|
||||
/// <summary>
|
||||
/// Background service execution loop that synchronizes profiles on a configurable interval.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
|
||||
/// <returns>A task representing the background execution.</returns>
|
||||
/// <remarks>
|
||||
/// The loop continues until:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Application shutdown is requested (via stoppingToken)</description></item>
|
||||
/// <item><description>An unhandled exception causes service failure</description></item>
|
||||
/// </list>
|
||||
/// Uses scoped services for each iteration to ensure proper lifetime management.
|
||||
/// </remarks>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Logger.LogInformation("ProfileWorker started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
|
||||
await work.ExecuteAsync(stoppingToken);
|
||||
|
||||
// Success - update health state
|
||||
_lastSuccessfulRun = DateTime.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 <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
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal file
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for ProfileWorker background service.
|
||||
/// Binds to "ProfileWorker" section in appsettings.json.
|
||||
/// </summary>
|
||||
public class ProfileWorkerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding options.
|
||||
/// </summary>
|
||||
public const string SectionName = "ProfileWorker";
|
||||
|
||||
/// <summary>
|
||||
/// Interval in milliseconds between profile synchronization checks.
|
||||
/// Default: 1000ms (1 second).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lower values increase responsiveness but consume more resources.
|
||||
/// Recommended range: 1000-5000ms.
|
||||
/// </remarks>
|
||||
public int IntervalMS { get; set; } = 1000;
|
||||
}
|
||||
@@ -1,9 +1,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)
|
||||
|
||||
@@ -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&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>
|
||||
@@ -13,7 +13,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"launchUrl": "",
|
||||
"applicationUrl": "http://localhost:5271",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
@@ -23,7 +23,7 @@
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"launchUrl": "",
|
||||
"applicationUrl": "https://localhost:7027;http://localhost:5271",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
@@ -32,7 +32,7 @@
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"launchUrl": "",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
|
||||
@@ -3,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal file
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal file
@@ -0,0 +1,96 @@
|
||||
body {
|
||||
padding: 40px 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container-fluid { max-width: 1400px; }
|
||||
|
||||
.status-healthy { color: #198754; }
|
||||
.status-degraded { color: #ffc107; }
|
||||
.status-unhealthy { color: #dc3545; }
|
||||
|
||||
.badge-healthy { background-color: #198754; }
|
||||
.badge-degraded { background-color: #ffc107; color: #000; }
|
||||
.badge-unhealthy { background-color: #dc3545; }
|
||||
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
border: none;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric:last-child { border-bottom: none; }
|
||||
|
||||
.metric-label { font-weight: 500; color: #666; }
|
||||
.metric-value { color: #333; font-weight: 600; }
|
||||
|
||||
.refresh-info {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 2s linear infinite;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.header-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
margin-right: 15px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal file
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal file
@@ -0,0 +1,15 @@
|
||||
let countdown = 3;
|
||||
|
||||
function updateCountdown() {
|
||||
document.getElementById('countdown').innerText = countdown;
|
||||
countdown--;
|
||||
if (countdown < 0) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function autoRefresh() {
|
||||
setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
window.onload = autoRefresh;
|
||||
Reference in New Issue
Block a user