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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -43,6 +47,13 @@ Global
|
|||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
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
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
|
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
@@ -6,42 +6,31 @@ namespace ECMJobRunner.Application.Common.Exceptions
|
|||||||
/// Exception thrown when attempting to execute a job for an inactive profile
|
/// Exception thrown when attempting to execute a job for an inactive profile
|
||||||
/// Extends JobException with profile-specific context
|
/// Extends JobException with profile-specific context
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Gets the name of the inactive profile (nullable)
|
/// Gets the name of the inactive profile (nullable)
|
||||||
/// </summary>
|
/// </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
|
/// Base exception class for job execution failures
|
||||||
/// Provides a flexible structure for capturing job context and detailed error information
|
/// Provides a flexible structure for capturing job context and detailed error information
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class JobException : Exception
|
/// <remarks>
|
||||||
{
|
/// Initializes a new instance of JobException with detailed context information
|
||||||
/// <summary>
|
/// </remarks>
|
||||||
/// Initializes a new instance of JobException with detailed context information
|
/// <param name="profileId">Identifier of the profile associated with the job</param>
|
||||||
/// </summary>
|
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</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="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="batchId">Unique batch identifier for tracking the execution</param>
|
/// <param name="reason">Human-readable reason for the failure (nullable)</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="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>
|
||||||
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
|
/// <remarks>
|
||||||
/// <remarks>
|
/// The details parameter accepts tuples with:
|
||||||
/// The details parameter accepts tuples with:
|
/// - Name: Display name of the detail
|
||||||
/// - Name: Display name of the detail
|
/// - Value: String value of the detail (nullable)
|
||||||
/// - Value: String value of the detail (nullable)
|
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
|
||||||
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
|
/// </remarks>
|
||||||
/// </remarks>
|
public class JobException(long profileId, string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
|
||||||
public JobException(string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
|
: Exception(
|
||||||
: base(
|
CreateMessage(jobName,
|
||||||
Message(jobName,
|
|
||||||
[
|
[
|
||||||
|
("Profile Id", profileId.ToString(), false),
|
||||||
|
("Job Name", jobName, false),
|
||||||
("Process Name", processName, false),
|
("Process Name", processName, false),
|
||||||
("Batch Id", batchId, false),
|
("Batch Id", batchId, false),
|
||||||
("Reason", reason, true),
|
("Reason", reason, true),
|
||||||
..details
|
..details
|
||||||
]),
|
]),
|
||||||
innerException)
|
innerException)
|
||||||
{
|
{
|
||||||
JobName = jobName;
|
/// <summary>
|
||||||
ProcessName = processName;
|
/// Gets the profile identifier associated with the job execution
|
||||||
BatchId = batchId;
|
/// </summary>
|
||||||
}
|
public long ProfileId { get; } = profileId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the name of the job that failed
|
/// Gets the name of the job that failed
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string JobName { get; }
|
public string JobName { get; } = jobName;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the name of the process/stage that was being executed when the failure occurred
|
/// Gets the name of the process/stage that was being executed when the failure occurred
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ProcessName { get; }
|
public string ProcessName { get; } = processName;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the unique batch identifier for tracking the execution
|
/// Gets the unique batch identifier for tracking the execution
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BatchId { get; }
|
public string BatchId { get; } = batchId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a formatted error message with job context and details
|
/// Generates a formatted error message with job context and details
|
||||||
@@ -65,26 +66,22 @@ namespace ECMJobRunner.Application.Common.Exceptions
|
|||||||
/// Message format:
|
/// Message format:
|
||||||
/// <code>
|
/// <code>
|
||||||
/// {jobName} could not be completed.
|
/// {jobName} could not be completed.
|
||||||
/// ─────────────────────────────────────────
|
|
||||||
/// Process Name: {processName}
|
/// Process Name: {processName}
|
||||||
/// Batch Id: {batchId}
|
/// Batch Id: {batchId}
|
||||||
/// {additional details...}
|
/// {additional details...}
|
||||||
/// ─────────────────────────────────────────
|
|
||||||
/// </code>
|
/// </code>
|
||||||
/// Details with IgnoreIfNull=true are omitted when their value is null.
|
/// Details with IgnoreIfNull=true are omitted when their value is null.
|
||||||
/// </remarks>
|
/// </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();
|
var message = new System.Text.StringBuilder();
|
||||||
message.AppendLine($"{jobName} could not be completed.");
|
message.AppendLine($"{jobName} could not be completed.");
|
||||||
message.AppendLine("─────────────────────────────────────────");
|
|
||||||
foreach (var (name, value, ignoreNullValue) in details)
|
foreach (var (name, value, ignoreNullValue) in details)
|
||||||
{
|
{
|
||||||
if (ignoreNullValue && value is null)
|
if (ignoreNullValue && value is null)
|
||||||
continue;
|
continue;
|
||||||
message.AppendLine($" {name}: {value}");
|
message.AppendLine($" {name}: {value}");
|
||||||
}
|
}
|
||||||
message.AppendLine("─────────────────────────────────────────");
|
|
||||||
return message.ToString();
|
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)
|
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
|
||||||
/// Extends JobException with client library and method context
|
/// Extends JobException with client library and method context
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
|
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ClientLibrary { get; }
|
public string? ClientLibrary { get; } = clientLibrary;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
|
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
|
||||||
/// </summary>
|
/// </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
|
/// Exception for SQL query execution failures
|
||||||
/// Extends JobException with SQL query context for debugging
|
/// Extends JobException with SQL query context for debugging
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Gets the SQL query that failed (nullable)
|
/// 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
|
/// This property is marked as virtual to allow derived classes to customize query handling
|
||||||
/// (e.g., sanitizing sensitive data, truncating long queries)
|
/// (e.g., sanitizing sensitive data, truncating long queries)
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public virtual string? Query { get; }
|
public virtual string? Query { get; } = query;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace ECMJobRunner.Application.Common.Mapping
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class ProfileMappingProfile : Profile
|
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()
|
public ProfileMappingProfile()
|
||||||
{
|
{
|
||||||
// CfgProfile -> CfgProfileDto
|
// CfgProfile -> CfgProfileDto
|
||||||
|
|||||||
@@ -1,105 +1,109 @@
|
|||||||
using ECMJobRunner.Application.Common.Constants;
|
using ECMJobRunner.Application.Common.Constants;
|
||||||
using System.Text.RegularExpressions;
|
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>
|
/// <summary>
|
||||||
/// Configuration options for DEX job execution
|
/// The configuration section name used to bind this options class from application settings
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DexJobOptions
|
public const string SectionName = "DexJob";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error handling options for DEX job operations
|
||||||
|
/// </summary>
|
||||||
|
public record DexJobErrorHandlingOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error handling options for DEX job operations
|
/// Error handling options for SQL query execution
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record DexJobErrorHandlingOptions
|
public record SqlQueryErrorHandlingOptions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error handling options for SQL query execution
|
/// Action to take when query execution fails
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record SqlQueryErrorHandlingOptions
|
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
|
||||||
{
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error handling options for HTTP requests
|
/// Action to take when query is null or whitespace
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record HttpRequestErrorHandlingOptions
|
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Action to take when HTTP request fails
|
|
||||||
/// </summary>
|
|
||||||
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error handling for main SQL query
|
/// Action to take when query returns unexpected result
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
|
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
|
||||||
|
|
||||||
/// <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>
|
/// <summary>
|
||||||
/// Error handling configuration
|
/// Error handling options for HTTP requests
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DexJobErrorHandlingOptions Error { get; set; } = new();
|
public record HttpRequestErrorHandlingOptions
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Placeholder configuration for dynamic value replacement
|
|
||||||
/// </summary>
|
|
||||||
public record PlaceHolderOptions
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configuration for a single placeholder
|
/// Action to take when HTTP request fails
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record PlaceHolder
|
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
|
||||||
{
|
|
||||||
/// <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>
|
/// <summary>
|
||||||
/// Placeholder configuration
|
/// Error handling for main SQL query
|
||||||
/// </summary>
|
/// </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 MediatR;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ReC.Client;
|
using ReC.Client;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -16,8 +18,10 @@ namespace ECMJobRunner.Application
|
|||||||
/// Registers MediatR, pipeline behaviors, and AutoMapper
|
/// Registers MediatR, pipeline behaviors, and AutoMapper
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection</param>
|
/// <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>
|
/// <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();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
@@ -27,13 +31,20 @@ namespace ECMJobRunner.Application
|
|||||||
#else
|
#else
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Register AutoMapper with all profiles from this assembly
|
||||||
|
services.AddAutoMapper(assembly);
|
||||||
services.AddRecClient(recClientApiUrl, opt =>
|
services.AddRecClient(recClientApiUrl, opt =>
|
||||||
{
|
{
|
||||||
opt.LogSuccessfulRequests = true;
|
opt.LogSuccessfulRequests = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configure DexJobOptions from appsettings.json
|
||||||
|
services.Configure<DexJobOptions>(configuration.GetSection(DexJobOptions.SectionName));
|
||||||
|
|
||||||
// Register pipeline behaviors in execution order
|
// Register pipeline behaviors in execution order
|
||||||
// Order matters: MainQuery -> CheckQuery -> ReCRequest
|
// Order matters: MainQuery -> CheckQuery -> ReCRequest
|
||||||
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(JobExceptionHandlingBehavior<,>));
|
||||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
|
||||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
|
||||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
|
<PackageReference Include="ReC.Client" Version="1.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -31,6 +31,8 @@
|
|||||||
<!-- MediatR for .NET Framework 4.8 -->
|
<!-- MediatR for .NET Framework 4.8 -->
|
||||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" 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>
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
<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.Exceptions;
|
||||||
using ECMJobRunner.Application.Common.Interfaces;
|
using ECMJobRunner.Application.Common.Interfaces;
|
||||||
using ECMJobRunner.Application.Common.Options;
|
using ECMJobRunner.Application.Common.Options;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System;
|
using System;
|
||||||
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.Behaviors
|
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pipeline behavior that executes the check SQL query for DEX jobs
|
/// 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)
|
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||||
#endif
|
#endif
|
||||||
{
|
{
|
||||||
if (request is TriggeringDEXJobCommand command)
|
if (request is TriggeringProfileJobCommand command)
|
||||||
{
|
{
|
||||||
await ExecuteCheckQueryAsync(command, cancellationToken);
|
await ExecuteCheckQueryAsync(command, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
return await next();
|
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))
|
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
|
||||||
{
|
{
|
||||||
@@ -71,6 +71,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
{
|
{
|
||||||
if (result is null)
|
if (result is null)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Check Query",
|
processName: "Check Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -79,6 +80,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
innerException: null);
|
innerException: null);
|
||||||
else if (result.ReturnValue <= 0)
|
else if (result.ReturnValue <= 0)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Check Query",
|
processName: "Check Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -91,6 +93,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
{
|
{
|
||||||
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
|
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Check Query",
|
processName: "Check Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -103,6 +106,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
||||||
{
|
{
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName:"Triggering DEX",
|
jobName:"Triggering DEX",
|
||||||
processName:"Check Query",
|
processName:"Check Query",
|
||||||
batchId:command.BatchId,
|
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.Exceptions;
|
||||||
using ECMJobRunner.Application.Common.Interfaces;
|
using ECMJobRunner.Application.Common.Interfaces;
|
||||||
using ECMJobRunner.Application.Common.Options;
|
using ECMJobRunner.Application.Common.Options;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System;
|
using System;
|
||||||
@@ -11,7 +11,7 @@ using System.Text.RegularExpressions;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.Behaviors
|
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pipeline behavior that executes the main SQL query for DEX jobs
|
/// 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)
|
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||||
#endif
|
#endif
|
||||||
{
|
{
|
||||||
if (request is TriggeringDEXJobCommand command)
|
if (request is TriggeringProfileJobCommand command)
|
||||||
{
|
{
|
||||||
await ExecuteMainQueryAsync(command, cancellationToken);
|
await ExecuteMainQueryAsync(command, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
return await next();
|
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))
|
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
|
||||||
{
|
{
|
||||||
@@ -71,18 +71,20 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
{
|
{
|
||||||
if (result is null)
|
if (result is null)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Main Query",
|
processName: "Main Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
reason: "Main Query returned nothing.",
|
reason: "Main Query returned nothing.",
|
||||||
query: sqlMainQuery,
|
query: sqlMainQuery,
|
||||||
innerException: null);
|
innerException: null);
|
||||||
else if (result.ReturnValue is not null)
|
else if (result.ReturnValue is not null && result.ReturnValue != 0)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Main Query",
|
processName: "Main Query",
|
||||||
batchId: command.BatchId,
|
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);
|
query: sqlMainQuery, innerException: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
{
|
{
|
||||||
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
|
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Main Query",
|
processName: "Main Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -101,6 +104,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
||||||
{
|
{
|
||||||
throw new JobSqlException(
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "Main Query",
|
processName: "Main Query",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using ECMJobRunner.Application.Common.Constants;
|
using ECMJobRunner.Application.Common.Constants;
|
||||||
using ECMJobRunner.Application.Common.Exceptions;
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
using ECMJobRunner.Application.Common.Options;
|
using ECMJobRunner.Application.Common.Options;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ReC.Client;
|
using ReC.Client;
|
||||||
using ReC.Client.Api;
|
using ReC.Client.Api;
|
||||||
@@ -10,7 +11,7 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.Behaviors
|
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
|
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
|
||||||
@@ -18,22 +19,15 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="TRequest">The request type</typeparam>
|
/// <typeparam name="TRequest">The request type</typeparam>
|
||||||
/// <typeparam name="TResponse">The response 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
|
where TRequest : notnull
|
||||||
{
|
{
|
||||||
private readonly ReCClient _reCClient;
|
private readonly DexJobOptions Options = options.Value;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the pipeline behavior
|
/// Handles the pipeline behavior
|
||||||
@@ -45,7 +39,7 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||||
#endif
|
#endif
|
||||||
{
|
{
|
||||||
if (request is TriggeringDEXJobCommand command)
|
if (request is TriggeringProfileJobCommand command)
|
||||||
{
|
{
|
||||||
await SendReCRequestAsync(command, cancellationToken);
|
await SendReCRequestAsync(command, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -53,20 +47,30 @@ namespace ECMJobRunner.Application.Behaviors
|
|||||||
return await next();
|
return await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendReCRequestAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
|
private async Task SendReCRequestAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _reCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
|
command.RecActionResult = await ReCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
|
||||||
{
|
{
|
||||||
BatchId = command.BatchId,
|
BatchId = command.BatchId,
|
||||||
}, cancel);
|
}, 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (_options.Error.ReCRequest.OnSending == ErrorAction.Stop)
|
if (Options.Error.ReCRequest.OnSending == ErrorAction.Stop)
|
||||||
{
|
{
|
||||||
throw new JobHttpException(
|
throw new JobHttpException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
jobName: "Triggering DEX",
|
jobName: "Triggering DEX",
|
||||||
processName: "ReC Http Request",
|
processName: "ReC Http Request",
|
||||||
batchId: command.BatchId,
|
batchId: command.BatchId,
|
||||||
@@ -6,13 +6,13 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.DEXJob.Commands
|
namespace ECMJobRunner.Application.Profiles.Commands
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Command to trigger DEX job batch for a profile
|
/// Command to trigger DEX job batch for a profile
|
||||||
/// Executes all SQL jobs associated with a profile ID
|
/// Executes all SQL jobs associated with a profile ID
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TriggeringDEXJobBatchCommand : IRequest<Unit>
|
public class TriggeringProfileJobBatchCommand : IRequest<Unit>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Profile ID to trigger all associated SQL jobs
|
/// 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
|
/// Validates that the profile is active before execution
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
|
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
|
||||||
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
|
: IRequestHandler<TriggeringProfileJobBatchCommand, Unit>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the TriggeringDEXJobBatchCommand
|
/// Handles the TriggeringDEXJobBatchCommand
|
||||||
@@ -36,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
|
|||||||
/// <param name="cancellationToken">Cancellation token</param>
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
/// <returns>Unit value indicating completion</returns>
|
/// <returns>Unit value indicating completion</returns>
|
||||||
/// <exception cref="InactiveProfileException">Thrown when the profile is not active</exception>
|
/// <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();
|
var batchId = CreateBatchId();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
|
|||||||
|
|
||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
{
|
{
|
||||||
await sender.Send(new TriggeringDEXJobCommand
|
await sender.Send(new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = job,
|
Job = job,
|
||||||
BatchId = batchId
|
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 MediatR;
|
||||||
|
using ReC.Client.Api;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.DEXJob.Commands
|
namespace ECMJobRunner.Application.Profiles.Commands
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Command to trigger a single DEX job execution
|
/// Command to trigger a single DEX job execution
|
||||||
@@ -12,7 +16,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
|
|||||||
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
|
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
|
||||||
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
|
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TriggeringDEXJobCommand : IRequest<Unit>
|
public class TriggeringProfileJobCommand : IRequest<Unit>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The SQL job to execute
|
/// The SQL job to execute
|
||||||
@@ -23,6 +27,8 @@ namespace ECMJobRunner.Application.DEXJob.Commands
|
|||||||
/// Unique batch identifier for this execution
|
/// Unique batch identifier for this execution
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BatchId { get; set; } = null!;
|
public string BatchId { get; set; } = null!;
|
||||||
|
|
||||||
|
internal BatchRecActionViewResponse? RecActionResult { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,7 +36,7 @@ namespace ECMJobRunner.Application.DEXJob.Commands
|
|||||||
/// All execution logic is delegated to pipeline behaviors
|
/// All execution logic is delegated to pipeline behaviors
|
||||||
/// This handler simply returns completion after behaviors execute
|
/// This handler simply returns completion after behaviors execute
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TriggeringDEXJobCommandHandler : IRequestHandler<TriggeringDEXJobCommand, Unit>
|
public class TriggeringDEXJobCommandHandler(ISender Sender) : IRequestHandler<TriggeringProfileJobCommand, Unit>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the TriggeringDEXJobCommand
|
/// 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="request">The command containing job and batch ID</param>
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
/// <returns>Unit value indicating completion</returns>
|
/// <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:
|
// All execution logic is handled by pipeline behaviors:
|
||||||
// - MainQueryExecutionBehavior
|
// - MainQueryExecutionBehavior
|
||||||
// - CheckQueryExecutionBehavior
|
// - CheckQueryExecutionBehavior
|
||||||
// - ReCRequestExecutionBehavior
|
// - 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.Application.Common.Dtos;
|
||||||
using ECMJobRunner.Domain.Interfaces;
|
using ECMJobRunner.Domain.Interfaces;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace ECMJobRunner.Application.DEXJob.Queries
|
namespace ECMJobRunner.Application.Profiles.Queries
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Query to retrieve profiles with flexible filtering options
|
/// Query to retrieve profiles with flexible filtering options
|
||||||
@@ -58,12 +59,23 @@ namespace ECMJobRunner.Application.DEXJob.Queries
|
|||||||
private readonly ICfgProfileRepository _profileRepository;
|
private readonly ICfgProfileRepository _profileRepository;
|
||||||
private readonly IMapper _mapper;
|
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)
|
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
|
||||||
{
|
{
|
||||||
_profileRepository = profileRepository;
|
_profileRepository = profileRepository;
|
||||||
_mapper = mapper;
|
_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)
|
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IEnumerable<Domain.Entities.CfgProfile> profiles;
|
IEnumerable<Domain.Entities.CfgProfile> profiles;
|
||||||
@@ -112,8 +124,11 @@ namespace ECMJobRunner.Application.DEXJob.Queries
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.ProfileName))
|
if (!string.IsNullOrWhiteSpace(request.ProfileName))
|
||||||
{
|
{
|
||||||
var searchName = request.ProfileName.ToLowerInvariant();
|
#if NET
|
||||||
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
|
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;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
@@ -35,6 +36,18 @@ namespace ECMJobRunner.Domain.Entities
|
|||||||
[Column("RESULT_ID")]
|
[Column("RESULT_ID")]
|
||||||
public byte ResultId { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Result text/message
|
/// Result text/message
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -90,10 +90,5 @@ namespace ECMJobRunner.Domain.Interfaces
|
|||||||
/// <param name="cancellationToken">Cancellation token</param>
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
/// <returns>True if entity was found and deleted, false otherwise</returns>
|
/// <returns>True if entity was found and deleted, false otherwise</returns>
|
||||||
Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
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>
|
/// </summary>
|
||||||
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
|
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.CfgProfiles
|
return await Context.CfgProfiles
|
||||||
.Include(p => p.SqlJobs)
|
.Include(p => p.SqlJobs)
|
||||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ namespace ECMJobRunner.Infrastructure.Repositories
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
|
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _context.CfgProfiles
|
return await Context.CfgProfiles
|
||||||
.Include(p => p.SqlJobs)
|
.Include(p => p.SqlJobs)
|
||||||
.Where(p => p.Active)
|
.Where(p => p.Active)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -20,145 +20,144 @@ namespace ECMJobRunner.Infrastructure.Repositories
|
|||||||
/// Uses AutoMapper for DTO mapping
|
/// Uses AutoMapper for DTO mapping
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="TEntity">Entity type</typeparam>
|
/// <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>
|
/// <summary>
|
||||||
/// Database context
|
/// Database context
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected readonly JobRunnerDbContext _context;
|
protected readonly JobRunnerDbContext Context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DbSet for the entity
|
/// DbSet for the entity
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected readonly DbSet<TEntity> _dbSet;
|
protected readonly DbSet<TEntity> DbSet = context.Set<TEntity>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AutoMapper instance for DTO mapping
|
/// AutoMapper instance for DTO mapping
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected readonly IMapper _mapper;
|
protected readonly IMapper Mapper = mapper ?? throw new ArgumentNullException(nameof(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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
#if NET48
|
#if NET48
|
||||||
return await _dbSet.FindAsync(cancellationToken, id);
|
return await DbSet.FindAsync(cancellationToken, id);
|
||||||
#else
|
#else
|
||||||
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
|
return await DbSet.FindAsync([id], cancellationToken);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.ToListAsync(cancellationToken);
|
return await DbSet.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
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/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
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/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
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));
|
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
|
#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;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
|
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));
|
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
|
#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/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
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));
|
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;
|
return 0;
|
||||||
|
|
||||||
foreach (var entity in entities)
|
foreach (var entity in entities)
|
||||||
{
|
{
|
||||||
_mapper.Map(dto, entity);
|
Mapper.Map(dto, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await SaveChangesAsync(cancellationToken);
|
return await Context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
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));
|
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||||
|
#endif
|
||||||
|
|
||||||
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
|
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
if (entity == null)
|
if (entity == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
_mapper.Map(dto, entity);
|
Mapper.Map(dto, entity);
|
||||||
|
|
||||||
await SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
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;
|
return 0;
|
||||||
|
|
||||||
_dbSet.RemoveRange(entities);
|
DbSet.RemoveRange(entities);
|
||||||
|
|
||||||
return await SaveChangesAsync(cancellationToken);
|
return await Context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -169,20 +168,10 @@ namespace ECMJobRunner.Infrastructure.Repositories
|
|||||||
if (entity == null)
|
if (entity == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
_dbSet.Remove(entity);
|
DbSet.Remove(entity);
|
||||||
|
|
||||||
await SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return true;
|
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
|
#if NET48
|
||||||
using System.Data.Entity;
|
using System.Data.Entity;
|
||||||
using System.Linq;
|
|
||||||
#else
|
#else
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
#endif
|
#endif
|
||||||
@@ -8,6 +7,7 @@ using ECMJobRunner.Application.Common.Interfaces;
|
|||||||
using ECMJobRunner.Infrastructure.Data;
|
using ECMJobRunner.Infrastructure.Data;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace ECMJobRunner.Infrastructure.Services
|
namespace ECMJobRunner.Infrastructure.Services
|
||||||
{
|
{
|
||||||
@@ -41,16 +41,16 @@ namespace ECMJobRunner.Infrastructure.Services
|
|||||||
// Entity Framework 6 implementation
|
// Entity Framework 6 implementation
|
||||||
var result = await _context.Database
|
var result = await _context.Database
|
||||||
.SqlQuery<TResult>(sql)
|
.SqlQuery<TResult>(sql)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return result;
|
return result.FirstOrDefault();
|
||||||
#else
|
#else
|
||||||
// Entity Framework Core implementation
|
// Entity Framework Core implementation
|
||||||
var result = await _context.Database
|
var result = await _context.Database
|
||||||
.SqlQueryRaw<TResult>(sql)
|
.SqlQueryRaw<TResult>(sql)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return result;
|
return result.FirstOrDefault();
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using ECMJobRunner.Application.Behaviors;
|
|
||||||
using ECMJobRunner.Application.Common.Constants;
|
using ECMJobRunner.Application.Common.Constants;
|
||||||
using ECMJobRunner.Application.Common.Dtos;
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
using ECMJobRunner.Application.Common.Exceptions;
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
using ECMJobRunner.Application.Common.Interfaces;
|
using ECMJobRunner.Application.Common.Interfaces;
|
||||||
using ECMJobRunner.Application.Common.Options;
|
using ECMJobRunner.Application.Common.Options;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.DEXJob.Commands;
|
||||||
|
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
|
||||||
using ECMJobRunner.Domain.Entities;
|
using ECMJobRunner.Domain.Entities;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
{
|
{
|
||||||
private readonly Mock<ISQLExecutor> _mockExecutor;
|
private readonly Mock<ISQLExecutor> _mockExecutor;
|
||||||
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
||||||
private readonly CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
|
private readonly CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
|
||||||
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
||||||
|
|
||||||
public CheckQueryExecutionBehaviorTests()
|
public CheckQueryExecutionBehaviorTests()
|
||||||
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
_mockExecutor = new Mock<ISQLExecutor>();
|
_mockExecutor = new Mock<ISQLExecutor>();
|
||||||
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
||||||
_mockOptions.Setup(o => o.Value).Returns(new 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 = new Mock<RequestHandlerDelegate<Unit>>();
|
||||||
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
|
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
|
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -68,7 +68,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
|
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -96,7 +96,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
|
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -122,7 +122,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
|
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = null },
|
Job = new ProfileSqlJob { SqlCheckQuery = null },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -157,8 +157,8 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
};
|
};
|
||||||
_mockOptions.Setup(o => o.Value).Returns(options);
|
_mockOptions.Setup(o => o.Value).Returns(options);
|
||||||
|
|
||||||
var behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
var behavior = new CheckQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = null },
|
Job = new ProfileSqlJob { SqlCheckQuery = null },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -181,7 +181,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var batchId = "20260711143025123456";
|
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'" },
|
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" },
|
||||||
BatchId = batchId
|
BatchId = batchId
|
||||||
@@ -209,7 +209,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithNullResult_ThrowsDEXJobException()
|
public async Task Handle_WithNullResult_ThrowsDEXJobException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using ECMJobRunner.Application.Behaviors;
|
|
||||||
using ECMJobRunner.Application.Common.Constants;
|
using ECMJobRunner.Application.Common.Constants;
|
||||||
using ECMJobRunner.Application.Common.Dtos;
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
using ECMJobRunner.Application.Common.Exceptions;
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
using ECMJobRunner.Application.Common.Interfaces;
|
using ECMJobRunner.Application.Common.Interfaces;
|
||||||
using ECMJobRunner.Application.Common.Options;
|
using ECMJobRunner.Application.Common.Options;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.DEXJob.Commands;
|
||||||
|
using ECMJobRunner.Application.DEXJob.Commands.Behaviors;
|
||||||
using ECMJobRunner.Domain.Entities;
|
using ECMJobRunner.Domain.Entities;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -21,7 +21,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
{
|
{
|
||||||
private readonly Mock<ISQLExecutor> _mockExecutor;
|
private readonly Mock<ISQLExecutor> _mockExecutor;
|
||||||
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
||||||
private readonly MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
|
private readonly MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit> _behavior;
|
||||||
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
||||||
|
|
||||||
public MainQueryExecutionBehaviorTests()
|
public MainQueryExecutionBehaviorTests()
|
||||||
@@ -29,7 +29,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
_mockExecutor = new Mock<ISQLExecutor>();
|
_mockExecutor = new Mock<ISQLExecutor>();
|
||||||
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
||||||
_mockOptions.Setup(o => o.Value).Returns(new 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 = new Mock<RequestHandlerDelegate<Unit>>();
|
||||||
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
|
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
|
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -65,7 +65,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithNullReturnValue_DoesNotThrow()
|
public async Task Handle_WithNullReturnValue_DoesNotThrow()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -90,7 +90,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
|
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -116,7 +116,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
|
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = null },
|
Job = new ProfileSqlJob { SqlMainQuery = null },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -151,8 +151,8 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
};
|
};
|
||||||
_mockOptions.Setup(o => o.Value).Returns(options);
|
_mockOptions.Setup(o => o.Value).Returns(options);
|
||||||
|
|
||||||
var behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
var behavior = new MainQueryExecutionBehavior<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = null },
|
Job = new ProfileSqlJob { SqlMainQuery = null },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
@@ -175,7 +175,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var batchId = "20260711143025123456";
|
var batchId = "20260711143025123456";
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
|
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
|
||||||
BatchId = batchId
|
BatchId = batchId
|
||||||
@@ -203,7 +203,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
|
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var command = new TriggeringDEXJobCommand
|
var command = new TriggeringProfileJobCommand
|
||||||
{
|
{
|
||||||
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
|
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
|
||||||
BatchId = "20260711143025123456"
|
BatchId = "20260711143025123456"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(jobs);
|
.ReturnsAsync(jobs);
|
||||||
|
|
||||||
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
|
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -54,7 +54,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
// Assert
|
// Assert
|
||||||
result.Should().Be(Unit.Value);
|
result.Should().Be(Unit.Value);
|
||||||
_mockSender.Verify(
|
_mockSender.Verify(
|
||||||
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
|
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Exactly(2),
|
Times.Exactly(2),
|
||||||
"Should send command for each job");
|
"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>()))
|
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
|
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
|
||||||
|
|
||||||
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
|
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -76,7 +76,7 @@ namespace ECMJobRunner.Tests.Application
|
|||||||
// Assert
|
// Assert
|
||||||
result.Should().Be(Unit.Value);
|
result.Should().Be(Unit.Value);
|
||||||
_mockSender.Verify(
|
_mockSender.Verify(
|
||||||
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
|
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()),
|
||||||
Times.Never,
|
Times.Never,
|
||||||
"Should not send any commands when no jobs found");
|
"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>()))
|
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new[] { job });
|
.ReturnsAsync(new[] { job });
|
||||||
|
|
||||||
TriggeringDEXJobCommand? capturedCommand = null;
|
TriggeringProfileJobCommand? capturedCommand = null;
|
||||||
_mockSender
|
_mockSender
|
||||||
.Setup(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringDEXJobCommand)
|
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringProfileJobCommand)
|
||||||
.ReturnsAsync(Unit.Value);
|
.ReturnsAsync(Unit.Value);
|
||||||
|
|
||||||
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
|
var command = new TriggeringProfileJobBatchCommand { ProfileId = profileId };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||||
|
|
||||||
<!-- ReC.Client for HTTP mocking -->
|
<!-- ReC.Client for HTTP mocking -->
|
||||||
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
|
<PackageReference Include="ReC.Client" Version="1.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- .NET 8.0 specific packages -->
|
<!-- .NET 8.0 specific packages -->
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ namespace ECMJobRunner.WebCron
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
|
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)
|
public bool Authorize(DashboardContext context)
|
||||||
{
|
{
|
||||||
// Allow all users - FOR DEVELOPMENT ONLY
|
// Allow all users - FOR DEVELOPMENT ONLY
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net8.0</TargetFrameworks>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
using ECMJobRunner.Application.Common.Dtos;
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
using ECMJobRunner.Application.DEXJob.Commands;
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace ECMJobRunner.WebCron.Extensions;
|
namespace ECMJobRunner.WebCron.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for profile DTOs to support Hangfire job operations.
|
||||||
|
/// </summary>
|
||||||
public static class DtoExtensions
|
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)
|
public static string JobId(this CfgProfileDto profile)
|
||||||
{
|
{
|
||||||
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
|
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,
|
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.Application;
|
||||||
using ECMJobRunner.Infrastructure;
|
using ECMJobRunner.Infrastructure;
|
||||||
using ECMJobRunner.WebCron;
|
using ECMJobRunner.WebCron;
|
||||||
|
using ECMJobRunner.WebCron.HealthCheck;
|
||||||
|
using ECMJobRunner.WebCron.Middleware;
|
||||||
|
using ECMJobRunner.WebCron.ProfileWorker;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.SqlServer;
|
using Hangfire.SqlServer;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Ui.Core.Extensions;
|
using Serilog.Ui.Core.Extensions;
|
||||||
using Serilog.Ui.SqliteDataProvider.Extensions;
|
using Serilog.Ui.SqliteDataProvider.Extensions;
|
||||||
@@ -19,8 +23,8 @@ var tempConfig = new ConfigurationBuilder()
|
|||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
// Get log directory from configuration
|
// Get log directory from configuration
|
||||||
var logDirectory = tempConfig.GetValue<string>("Logging:LogDirectory")
|
var logDirectory = tempConfig.GetValue<string>("Application:LogDirectory")
|
||||||
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
|
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
||||||
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
||||||
|
|
||||||
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
|
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
|
||||||
@@ -53,7 +57,7 @@ try
|
|||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddHostedService<ProfileManager>();
|
builder.Services.AddProfileWorker(builder.Configuration);
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
|
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
|
||||||
@@ -62,7 +66,7 @@ builder.Services.AddJobRunnerInfrastructure(cnnStr);
|
|||||||
|
|
||||||
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
|
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
|
||||||
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
|
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
|
||||||
builder.Services.AddJobRunnerServices(recClientApiUrl);
|
builder.Services.AddJobRunnerServices(recClientApiUrl, builder.Configuration);
|
||||||
|
|
||||||
// Get Hangfire storage configuration
|
// Get Hangfire storage configuration
|
||||||
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
|
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
|
||||||
@@ -100,9 +104,13 @@ builder.Services.AddHangfireServer();
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
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
|
// Add Serilog.UI with SQLite provider - use same path from configuration
|
||||||
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Logging:LogDirectory")
|
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
||||||
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
|
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
||||||
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
|
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
|
||||||
|
|
||||||
builder.Services.AddSerilogUi(logUIOpt =>
|
builder.Services.AddSerilogUi(logUIOpt =>
|
||||||
@@ -116,6 +124,8 @@ builder.Services.AddSerilogUi(logUIOpt =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -125,12 +135,28 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Enable static files for wwwroot
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// Add Hangfire Dashboard with no authentication (for development)
|
// Add Hangfire Dashboard with no authentication (for development)
|
||||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
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
|
// Add Serilog.UI Dashboard
|
||||||
@@ -138,6 +164,39 @@ app.UseSerilogUi();
|
|||||||
|
|
||||||
app.MapControllers();
|
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();
|
app.Run();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "hangfire",
|
"launchUrl": "",
|
||||||
"applicationUrl": "http://localhost:5271",
|
"applicationUrl": "http://localhost:5271",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "hangfire",
|
"launchUrl": "",
|
||||||
"applicationUrl": "https://localhost:7027;http://localhost:5271",
|
"applicationUrl": "https://localhost:7027;http://localhost:5271",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"IIS Express": {
|
"IIS Express": {
|
||||||
"commandName": "IISExpress",
|
"commandName": "IISExpress",
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"launchUrl": "hangfire",
|
"launchUrl": "",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
},
|
}
|
||||||
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
|
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"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": "*",
|
"AllowedHosts": "*",
|
||||||
"HostingOptions": {
|
"HostingOptions": {
|
||||||
@@ -18,5 +17,34 @@
|
|||||||
},
|
},
|
||||||
"ReC": {
|
"ReC": {
|
||||||
"ApiUrl": "http://172.24.12.39:90"
|
"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