From a928c2af2ac3fbbffc10c4cc676f881bcbdd0b73 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 2 Jun 2026 21:12:32 +0200 Subject: [PATCH] Adds operational Dashboard API for Elsa Studio (#7681) * Add dashboard API foundation Merge dashboard API foundation into dashboard-v2 * Harden dashboard API provider Documents the dashboard API contract, adds provider behavior tests, and degrades diagnostics capability states independently. * Add DashboardApi configuration to appsettings.json --- Elsa.sln | 30 ++ .../Elsa.ModularServer.Web.csproj | 1 + src/apps/Elsa.ModularServer.Web/Program.cs | 2 + .../Elsa.ModularServer.Web/appsettings.json | 1 + .../Elsa.Server.Web/Elsa.Server.Web.csproj | 1 + src/apps/Elsa.Server.Web/Program.cs | 1 + .../Elsa.Dashboard.Api/AssemblyInfo.cs | 3 + .../Contracts/IDashboardProvider.cs | 16 + .../Elsa.Dashboard.Api.csproj | 22 + .../Dashboard/NeedsAttention/Endpoint.cs | 25 ++ .../Endpoints/Dashboard/Overview/Endpoint.cs | 24 ++ .../Dashboard/RecentActivity/Endpoint.cs | 25 ++ .../Dashboard/WorkflowHotspots/Endpoint.cs | 22 + .../Dashboard/WorkflowTrends/Endpoint.cs | 22 + .../Extensions/ModuleExtensions.cs | 13 + .../Extensions/ServiceCollectionExtensions.cs | 15 + .../Features/DashboardApiFeature.cs | 25 ++ .../Elsa.Dashboard.Api/FodyWeavers.xml | 3 + .../Models/DashboardModels.cs | 211 ++++++++++ .../Permissions/DashboardPermissions.cs | 6 + src/modules/Elsa.Dashboard.Api/README.md | 79 ++++ .../Services/DashboardRangeResolver.cs | 47 +++ .../Services/DefaultDashboardProvider.cs | 396 ++++++++++++++++++ .../ShellFeatures/DashboardApiFeature.cs | 20 + .../DashboardRangeResolverTests.cs | 55 +++ .../DefaultDashboardProviderTests.cs | 316 ++++++++++++++ .../Elsa.Dashboard.Api.UnitTests.csproj | 11 + 27 files changed, 1392 insertions(+) create mode 100644 src/modules/Elsa.Dashboard.Api/AssemblyInfo.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj create mode 100644 src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs create mode 100644 src/modules/Elsa.Dashboard.Api/FodyWeavers.xml create mode 100644 src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Permissions/DashboardPermissions.cs create mode 100644 src/modules/Elsa.Dashboard.Api/README.md create mode 100644 src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs create mode 100644 src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs create mode 100644 src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs create mode 100644 test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs create mode 100644 test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs create mode 100644 test/unit/Elsa.Dashboard.Api.UnitTests/Elsa.Dashboard.Api.UnitTests.csproj diff --git a/Elsa.sln b/Elsa.sln index 496846670..1162fd8cf 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -369,6 +369,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Persistence.EF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Persistence.EFCore.Oracle", "src\modules\Elsa.Secrets.Persistence.EFCore.Oracle\Elsa.Secrets.Persistence.EFCore.Oracle.csproj", "{729FD075-2863-48AB-A3C0-A4F7A9277263}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Api", "src\modules\Elsa.Dashboard.Api\Elsa.Dashboard.Api.csproj", "{4FD4C59B-2804-4F6B-AD38-2562CC01C510}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Api.UnitTests", "test\unit\Elsa.Dashboard.Api.UnitTests\Elsa.Dashboard.Api.UnitTests.csproj", "{157EDBA7-B04F-4EA0-8377-434D1065ACF6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1529,6 +1533,30 @@ Global {729FD075-2863-48AB-A3C0-A4F7A9277263}.Release|x64.Build.0 = Release|Any CPU {729FD075-2863-48AB-A3C0-A4F7A9277263}.Release|x86.ActiveCfg = Release|Any CPU {729FD075-2863-48AB-A3C0-A4F7A9277263}.Release|x86.Build.0 = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|x64.Build.0 = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Debug|x86.Build.0 = Debug|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|Any CPU.Build.0 = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|x64.ActiveCfg = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|x64.Build.0 = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|x86.ActiveCfg = Release|Any CPU + {4FD4C59B-2804-4F6B-AD38-2562CC01C510}.Release|x86.Build.0 = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|x64.ActiveCfg = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|x64.Build.0 = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Debug|x86.Build.0 = Debug|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|Any CPU.Build.0 = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x64.ActiveCfg = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x64.Build.0 = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x86.ActiveCfg = Release|Any CPU + {157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1663,6 +1691,8 @@ Global {4DEE0574-C5B3-4765-9C48-89D384A17780} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} {151C46EF-FE3D-400B-8CBE-F86886B3EB96} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} {8F4AD54E-8586-4D8C-82E6-69218DD4280F} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D} + {4FD4C59B-2804-4F6B-AD38-2562CC01C510} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {157EDBA7-B04F-4EA0-8377-434D1065ACF6} = {18453B51-25EB-4317-A4B3-B10518252E92} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj index 736a2a1d0..bdf06fe5e 100644 --- a/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj +++ b/src/apps/Elsa.ModularServer.Web/Elsa.ModularServer.Web.csproj @@ -11,6 +11,7 @@ + diff --git a/src/apps/Elsa.ModularServer.Web/Program.cs b/src/apps/Elsa.ModularServer.Web/Program.cs index f27fae972..7f186d3a6 100644 --- a/src/apps/Elsa.ModularServer.Web/Program.cs +++ b/src/apps/Elsa.ModularServer.Web/Program.cs @@ -2,6 +2,7 @@ using ConsoleLogStreaming.Core.Capture; using CShells.AspNetCore.Configuration; using CShells.AspNetCore.Extensions; using CShells.DependencyInjection; +using Elsa.Dashboard.Api.ShellFeatures; using Elsa.ModularServer.Web; using Elsa.ModularServer.Web.Catalog; using Elsa.ShellFeatures; @@ -73,6 +74,7 @@ builder.AddShells(shells => shells typeof(WorkflowRuntimeFeature), typeof(WorkflowsFeature), typeof(DistributedRuntimeFeature), + typeof(DashboardApiFeature), typeof(WorkflowsApiFeature)); })); diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json index ed66320c9..b8b6d4b25 100644 --- a/src/apps/Elsa.ModularServer.Web/appsettings.json +++ b/src/apps/Elsa.ModularServer.Web/appsettings.json @@ -59,6 +59,7 @@ "EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk=" }, "SecretsJavaScript": {}, + "DashboardApi": {}, "SqliteAlterationsPersistence": { "ConnectionString": "Data Source=elsa_workflows.db;Cache=Shared" }, diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index 6c703baa9..0845c579a 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -3,6 +3,7 @@ + diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index b09694ddf..77ad5a2c8 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -95,6 +95,7 @@ services runtime.DistributedLockingOptions = options => options.AllowLocalLockProviderInDistributedRuntime = allowLocalDistributedRuntimeLockProvider; }) .UseWorkflowsApi() + .UseDashboardApi() .UseFluentStorageProvider() .UseElsaScriptBlobStorage() .UseScheduling() diff --git a/src/modules/Elsa.Dashboard.Api/AssemblyInfo.cs b/src/modules/Elsa.Dashboard.Api/AssemblyInfo.cs new file mode 100644 index 000000000..16f9e8aa1 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Elsa.Dashboard.Api.UnitTests")] diff --git a/src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs b/src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs new file mode 100644 index 000000000..4c5136e9a --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs @@ -0,0 +1,16 @@ +using Elsa.Dashboard.Api.Models; + +namespace Elsa.Dashboard.Api.Contracts; + +public interface IDashboardProvider +{ + Task GetOverviewAsync(DashboardQuery query, CancellationToken cancellationToken = default); + + Task GetWorkflowTrendsAsync(DashboardTrendRequest request, CancellationToken cancellationToken = default); + + Task GetNeedsAttentionAsync(DashboardQuery query, int take, CancellationToken cancellationToken = default); + + Task GetRecentActivityAsync(DashboardQuery query, int take, CancellationToken cancellationToken = default); + + Task GetWorkflowHotspotsAsync(DashboardWorkflowHotspotsRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj b/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj new file mode 100644 index 000000000..0cf5c4f88 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj @@ -0,0 +1,22 @@ + + + + Provides operational dashboard API endpoints for Elsa hosts. + elsa module dashboard operations workflows diagnostics + + + + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs new file mode 100644 index 000000000..f5aac1ed2 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs @@ -0,0 +1,25 @@ +using Elsa.Abstractions; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Dashboard.Api.Endpoints.Dashboard.NeedsAttention; + +[PublicAPI] +internal class Endpoint(IDashboardProvider dashboardProvider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/dashboard/needs-attention"); + ConfigurePermissions(DashboardPermissions.Read); + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var range = Query("range", false); + var take = Query("take", false) ?? 8; + var includeSystem = Query("includeSystem", false); + return await dashboardProvider.GetNeedsAttentionAsync(new(range, includeSystem), take, cancellationToken); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs new file mode 100644 index 000000000..d71e95bd2 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs @@ -0,0 +1,24 @@ +using Elsa.Abstractions; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Dashboard.Api.Endpoints.Dashboard.Overview; + +[PublicAPI] +internal class Endpoint(IDashboardProvider dashboardProvider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/dashboard/overview"); + ConfigurePermissions(DashboardPermissions.Read); + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var range = Query("range", false); + var includeSystem = Query("includeSystem", false); + return await dashboardProvider.GetOverviewAsync(new(range, includeSystem), cancellationToken); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs new file mode 100644 index 000000000..3305d734f --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs @@ -0,0 +1,25 @@ +using Elsa.Abstractions; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Dashboard.Api.Endpoints.Dashboard.RecentActivity; + +[PublicAPI] +internal class Endpoint(IDashboardProvider dashboardProvider) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Get("/dashboard/recent-activity"); + ConfigurePermissions(DashboardPermissions.Read); + } + + public override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var range = Query("range", false); + var take = Query("take", false) ?? 20; + var includeSystem = Query("includeSystem", false); + return await dashboardProvider.GetRecentActivityAsync(new(range, includeSystem), take, cancellationToken); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs new file mode 100644 index 000000000..955ac6b7a --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Dashboard.Api.Endpoints.Dashboard.WorkflowHotspots; + +[PublicAPI] +internal class Endpoint(IDashboardProvider dashboardProvider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/dashboard/workflow-hotspots"); + ConfigurePermissions(DashboardPermissions.Read); + } + + public override async Task ExecuteAsync(DashboardWorkflowHotspotsRequest request, CancellationToken cancellationToken) + { + return await dashboardProvider.GetWorkflowHotspotsAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs new file mode 100644 index 000000000..2fb0479c9 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs @@ -0,0 +1,22 @@ +using Elsa.Abstractions; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Permissions; +using JetBrains.Annotations; + +namespace Elsa.Dashboard.Api.Endpoints.Dashboard.WorkflowTrends; + +[PublicAPI] +internal class Endpoint(IDashboardProvider dashboardProvider) : ElsaEndpoint +{ + public override void Configure() + { + Post("/dashboard/workflow-trends"); + ConfigurePermissions(DashboardPermissions.Read); + } + + public override async Task ExecuteAsync(DashboardTrendRequest request, CancellationToken cancellationToken) + { + return await dashboardProvider.GetWorkflowTrendsAsync(request, cancellationToken); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Dashboard.Api/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..66b3954ac --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Extensions/ModuleExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Dashboard.Api.Features; +using Elsa.Features.Services; + +namespace Elsa.Extensions; + +public static class DashboardModuleExtensions +{ + public static IModule UseDashboardApi(this IModule module, Action? configure = null) + { + module.Configure(configure); + return module; + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..397fc83fe --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Dashboard.Api.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddDashboardApiServices(this IServiceCollection services) + { + return services + .AddScoped() + .AddScoped(); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs b/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs new file mode 100644 index 000000000..6b357b36d --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs @@ -0,0 +1,25 @@ +using Elsa.Dashboard.Api.Extensions; +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Workflows.Management.Features; +using Elsa.Workflows.Runtime.Features; + +namespace Elsa.Dashboard.Api.Features; + +[DependsOn(typeof(WorkflowInstancesFeature))] +[DependsOn(typeof(WorkflowRuntimeFeature))] +public class DashboardApiFeature(IModule module) : FeatureBase(module) +{ + public override void Configure() + { + Module.AddFastEndpointsAssembly(); + } + + public override void Apply() + { + Services.AddDashboardApiServices(); + Module.AddFastEndpointsFromModule(); + } +} diff --git a/src/modules/Elsa.Dashboard.Api/FodyWeavers.xml b/src/modules/Elsa.Dashboard.Api/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs b/src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs new file mode 100644 index 000000000..93b6d4694 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs @@ -0,0 +1,211 @@ +namespace Elsa.Dashboard.Api.Models; + +public record DashboardQuery(string? Range = null, bool IncludeSystem = false); + +public record DashboardOverview +{ + public DashboardCapabilityStatus Capability { get; init; } = DashboardCapabilityStatus.Available; + public string? BackendName { get; init; } + public string? EnvironmentName { get; init; } + public DashboardRuntimeStatus Runtime { get; init; } = new(); + public DashboardWorkflowInstanceMetrics WorkflowInstances { get; init; } = new(); + public DashboardDiagnosticsSummary Diagnostics { get; init; } = new(); + public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; + public DateTimeOffset From { get; init; } + public DateTimeOffset To { get; init; } +} + +public record DashboardCapabilityStatus +{ + public static DashboardCapabilityStatus Available { get; } = new("Available"); + public static DashboardCapabilityStatus NotInstalled { get; } = new("NotInstalled"); + public static DashboardCapabilityStatus Unauthorized { get; } = new("Unauthorized"); + public static DashboardCapabilityStatus Unavailable { get; } = new("Unavailable"); + + public DashboardCapabilityStatus(string status, string? reason = null) + { + Status = status; + Reason = reason; + } + + public string Status { get; init; } + public string? Reason { get; init; } +} + +public record DashboardRuntimeStatus +{ + public string Status { get; init; } = DashboardRuntimeStatusKeys.Unavailable; + public bool IsAcceptingWork { get; init; } + public int ActiveExecutionCycleCount { get; init; } + public int IngressSourceCount { get; init; } + public int FailedIngressSourceCount { get; init; } + public DateTimeOffset? PausedAt { get; init; } + public DateTimeOffset? DrainStartedAt { get; init; } + public string? Reason { get; init; } +} + +public record DashboardWorkflowInstanceMetrics +{ + public long Running { get; init; } + public long Completed { get; init; } + public long Faulted { get; init; } + public long Suspended { get; init; } + public long Interrupted { get; init; } + public long IncidentBearing { get; init; } + public TimeSpan? AverageDuration { get; init; } +} + +public record DashboardDiagnosticsSummary +{ + public DashboardStructuredLogSummary StructuredLogs { get; init; } = new(); + public DashboardConsoleLogSummary ConsoleLogs { get; init; } = new(); +} + +public record DashboardStructuredLogSummary +{ + public DashboardCapabilityStatus Capability { get; init; } = DashboardCapabilityStatus.NotInstalled; + public int SourceCount { get; init; } + public int StaleSourceCount { get; init; } + public int RecentErrorOrCriticalCount { get; init; } + public long DroppedWriteCount { get; init; } + public long DroppedEventCount { get; init; } +} + +public record DashboardConsoleLogSummary +{ + public DashboardCapabilityStatus Capability { get; init; } = DashboardCapabilityStatus.NotInstalled; + public int SourceCount { get; init; } + public int StaleSourceCount { get; init; } + public int RecentStderrCount { get; init; } + public long DroppedLineCount { get; init; } +} + +public record DashboardFinding +{ + public string Id { get; init; } = null!; + public string Severity { get; init; } = DashboardFindingSeverity.Info; + public string Message { get; init; } = null!; + public string? TargetKind { get; init; } + public string? Target { get; init; } + public int Priority { get; init; } +} + +public record DashboardNeedsAttentionResponse +{ + public IReadOnlyCollection Findings { get; init; } = []; + public DashboardCapabilityStatus Capability { get; init; } = DashboardCapabilityStatus.Available; + public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; +} + +public record DashboardTrendRequest +{ + public string? Range { get; init; } + public string? Granularity { get; init; } + public bool IncludeSystem { get; init; } +} + +public record DashboardTrendResponse +{ + public IReadOnlyCollection Buckets { get; init; } = []; + public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; + public string Granularity { get; init; } = DashboardTrendGranularity.Hour; + public DateTimeOffset From { get; init; } + public DateTimeOffset To { get; init; } +} + +public record DashboardTrendBucket +{ + public DateTimeOffset From { get; init; } + public DateTimeOffset To { get; init; } + public long CreatedOrStarted { get; init; } + public long Finished { get; init; } + public long Faulted { get; init; } + public long Suspended { get; init; } + public long IncidentBearing { get; init; } +} + +public record DashboardRecentActivityItem +{ + public string InstanceId { get; init; } = null!; + public string DefinitionId { get; init; } = null!; + public string? WorkflowName { get; init; } + public string Status { get; init; } = null!; + public string SubStatus { get; init; } = null!; + public int IncidentCount { get; init; } + public TimeSpan? Duration { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } + public DateTimeOffset? FinishedAt { get; init; } +} + +public record DashboardRecentActivityResponse +{ + public IReadOnlyCollection Items { get; init; } = []; + public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; + public DateTimeOffset From { get; init; } + public DateTimeOffset To { get; init; } +} + +public record DashboardWorkflowHotspotsRequest +{ + public string? Range { get; init; } + public string Metric { get; init; } = DashboardHotspotMetric.Faults; + public int Take { get; init; } = 10; + public bool IncludeSystem { get; init; } +} + +public record DashboardWorkflowHotspotsResponse +{ + public IReadOnlyCollection Items { get; init; } = []; + public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; + public string Metric { get; init; } = DashboardHotspotMetric.Faults; + public DateTimeOffset From { get; init; } + public DateTimeOffset To { get; init; } +} + +public record DashboardHotspot +{ + public string DefinitionId { get; init; } = null!; + public string? WorkflowName { get; init; } + public long Value { get; init; } + public TimeSpan? AverageDuration { get; init; } +} + +public static class DashboardRangeKeys +{ + public const string OneHour = "1h"; + public const string TwentyFourHours = "24h"; + public const string SevenDays = "7d"; +} + +public static class DashboardRuntimeStatusKeys +{ + public const string AcceptingWork = "AcceptingWork"; + public const string Paused = "Paused"; + public const string Draining = "Draining"; + public const string Unavailable = "Unavailable"; +} + +public static class DashboardTrendGranularity +{ + public const string Minute = "minute"; + public const string Hour = "hour"; + public const string Day = "day"; +} + +public static class DashboardFindingSeverity +{ + public const string Info = "Info"; + public const string Warning = "Warning"; + public const string Error = "Error"; + public const string Critical = "Critical"; + public const string Success = "Success"; +} + +public static class DashboardHotspotMetric +{ + public const string Faults = "Faults"; + public const string Executions = "Executions"; + public const string Incidents = "Incidents"; + public const string Duration = "Duration"; +} diff --git a/src/modules/Elsa.Dashboard.Api/Permissions/DashboardPermissions.cs b/src/modules/Elsa.Dashboard.Api/Permissions/DashboardPermissions.cs new file mode 100644 index 000000000..df4c8d388 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Permissions/DashboardPermissions.cs @@ -0,0 +1,6 @@ +namespace Elsa.Dashboard.Api.Permissions; + +public static class DashboardPermissions +{ + public const string Read = "read:dashboard"; +} diff --git a/src/modules/Elsa.Dashboard.Api/README.md b/src/modules/Elsa.Dashboard.Api/README.md new file mode 100644 index 000000000..59693affc --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/README.md @@ -0,0 +1,79 @@ +# Elsa Dashboard API + +`Elsa.Dashboard.Api` exposes aggregate endpoints used by Elsa Studio's operational dashboard. Hosts opt in by enabling the dashboard API feature/module; older hosts that do not install this module simply do not expose the `/dashboard/*` routes. + +## Endpoints + +### `GET /dashboard/overview` + +Query parameters: + +- `range`: Optional dashboard range key. Supported values are `1h`, `24h`, and `7d`. Unknown or missing values resolve to `24h`. +- `includeSystem`: Optional boolean. Defaults to `false`; when false, workflow instance aggregates exclude system workflows. + +Returns: + +- Backend and environment names. +- Runtime status, including whether the runtime is accepting work, active execution cycle count, ingress source count, and failed ingress source count. +- Workflow instance metrics for running, completed, faulted, suspended, interrupted, incident-bearing, and average completed duration. +- Structured log and console log diagnostic summaries. +- Applied range and resolved `from`/`to` timestamps. + +### `POST /dashboard/workflow-trends` + +Body: + +- `range`: Optional range key. +- `granularity`: Optional bucket granularity. Defaults to `minute` for `1h`, `hour` for `24h`, and `day` for `7d`. +- `includeSystem`: Optional boolean. + +Returns ordered buckets with created/started, finished, faulted, suspended, and incident-bearing counts. + +### `GET /dashboard/needs-attention` + +Query parameters: + +- `range`: Optional range key. +- `take`: Optional maximum number of findings. Clamped to `1..50`. +- `includeSystem`: Optional boolean. + +Returns priority-ordered findings derived from runtime state, workflow metrics, and available diagnostics summaries. Consumers should preserve backend ordering. + +### `GET /dashboard/recent-activity` + +Query parameters: + +- `range`: Optional range key. +- `take`: Optional maximum number of workflow instance summaries. Clamped to `1..100`. +- `includeSystem`: Optional boolean. + +Returns compact workflow instance activity ordered by latest update. The response intentionally omits workflow variables, inputs, outputs, and execution state. + +### `POST /dashboard/workflow-hotspots` + +Body: + +- `range`: Optional range key. +- `metric`: One of `Faults`, `Executions`, `Incidents`, or `Duration`. +- `take`: Optional maximum number of rows. Clamped to `1..50`. +- `includeSystem`: Optional boolean. + +Returns top workflow definitions for the selected metric. Studio treats this panel as optional and may omit it if the endpoint is unavailable. + +## Capability States + +Diagnostics summaries carry a `capability` object: + +- `Available`: The diagnostic provider is installed and returned data. +- `NotInstalled`: The diagnostic provider is absent from the host. +- `Unauthorized`: The provider rejected access. +- `Unavailable`: The provider is installed but failed to produce a summary. + +Dashboard overview degrades each diagnostics capability independently so a structured log failure does not prevent workflow metrics, runtime status, or console diagnostics from rendering. + +## Studio Integration Notes + +- Studio should detect dashboard API support with a guarded dashboard call or feature metadata and show an explicit unavailable state when the endpoints are missing. +- Studio should keep the last successful dashboard snapshot visible after a refresh failure. +- Metric and finding targets should route to existing workflow instance, structured log, and console pages. Destination pages that do not yet support URL filters should still be linked and can add deep-link filters later. +- The API is read-only. Dashboard consumers must not add workflow write actions to the first dashboard slice. diff --git a/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs b/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs new file mode 100644 index 000000000..b81d554cd --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs @@ -0,0 +1,47 @@ +using Elsa.Common; +using Elsa.Dashboard.Api.Models; + +namespace Elsa.Dashboard.Api.Services; + +public class DashboardRangeResolver(ISystemClock clock) +{ + public DashboardRange Resolve(string? range) + { + var key = Normalize(range); + var duration = key switch + { + DashboardRangeKeys.OneHour => TimeSpan.FromHours(1), + DashboardRangeKeys.SevenDays => TimeSpan.FromDays(7), + _ => TimeSpan.FromHours(24) + }; + var to = clock.UtcNow; + return new(key, to.Subtract(duration), to); + } + + public string ResolveGranularity(string? granularity, string range) => + string.IsNullOrWhiteSpace(granularity) + ? range switch + { + DashboardRangeKeys.OneHour => DashboardTrendGranularity.Minute, + DashboardRangeKeys.SevenDays => DashboardTrendGranularity.Day, + _ => DashboardTrendGranularity.Hour + } + : granularity; + + public TimeSpan GetBucketSize(string granularity) => + granularity.Equals(DashboardTrendGranularity.Minute, StringComparison.OrdinalIgnoreCase) + ? TimeSpan.FromMinutes(5) + : granularity.Equals(DashboardTrendGranularity.Day, StringComparison.OrdinalIgnoreCase) + ? TimeSpan.FromDays(1) + : TimeSpan.FromHours(1); + + private static string Normalize(string? range) => + range?.Trim().ToLowerInvariant() switch + { + DashboardRangeKeys.OneHour => DashboardRangeKeys.OneHour, + DashboardRangeKeys.SevenDays => DashboardRangeKeys.SevenDays, + _ => DashboardRangeKeys.TwentyFourHours + }; +} + +public record DashboardRange(string Key, DateTimeOffset From, DateTimeOffset To); diff --git a/src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs b/src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs new file mode 100644 index 000000000..49f6f4507 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs @@ -0,0 +1,396 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Models; +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Api.Models; +using Elsa.Diagnostics.StructuredLogs.Contracts; +using Elsa.Diagnostics.StructuredLogs.Models; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Dashboard.Api.Services; + +public class DefaultDashboardProvider( + IWorkflowInstanceStore workflowInstanceStore, + IWorkflowRuntimeAdminService runtimeAdminService, + DashboardRangeResolver rangeResolver, + IServiceProvider serviceProvider, + IHostEnvironment environment) : IDashboardProvider +{ + public async Task GetOverviewAsync(DashboardQuery query, CancellationToken cancellationToken = default) + { + var range = rangeResolver.Resolve(query.Range); + var runtime = GetRuntimeStatus(); + var workflowMetrics = await GetWorkflowMetricsAsync(range, query.IncludeSystem, cancellationToken); + var diagnostics = await GetDiagnosticsSummaryAsync(range, cancellationToken); + + return new() + { + BackendName = environment.ApplicationName, + EnvironmentName = environment.EnvironmentName, + Runtime = runtime, + WorkflowInstances = workflowMetrics, + Diagnostics = diagnostics, + AppliedRange = range.Key, + From = range.From, + To = range.To + }; + } + + public async Task GetWorkflowTrendsAsync(DashboardTrendRequest request, CancellationToken cancellationToken = default) + { + var range = rangeResolver.Resolve(request.Range); + var granularity = rangeResolver.ResolveGranularity(request.Granularity, range.Key); + var bucketSize = rangeResolver.GetBucketSize(granularity); + var buckets = new List(); + + for (var bucketFrom = range.From; bucketFrom < range.To; bucketFrom = bucketFrom.Add(bucketSize)) + { + var bucketTo = Min(bucketFrom.Add(bucketSize), range.To); + buckets.Add(new() + { + From = bucketFrom, + To = bucketTo, + CreatedOrStarted = await CountAsync(request.IncludeSystem, nameof(WorkflowInstance.CreatedAt), bucketFrom, bucketTo, cancellationToken), + Finished = await CountAsync(request.IncludeSystem, nameof(WorkflowInstance.FinishedAt), bucketFrom, bucketTo, cancellationToken, subStatus: WorkflowSubStatus.Finished), + Faulted = await CountAsync(request.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, cancellationToken, subStatus: WorkflowSubStatus.Faulted), + Suspended = await CountAsync(request.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, cancellationToken, subStatus: WorkflowSubStatus.Suspended), + IncidentBearing = await CountAsync(request.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, cancellationToken, hasIncidents: true) + }); + } + + return new() + { + Buckets = buckets, + AppliedRange = range.Key, + Granularity = granularity, + From = range.From, + To = range.To + }; + } + + public async Task GetNeedsAttentionAsync(DashboardQuery query, int take, CancellationToken cancellationToken = default) + { + var range = rangeResolver.Resolve(query.Range); + var overview = await GetOverviewAsync(query, cancellationToken); + var findings = new List(); + + if (overview.Runtime.Status == DashboardRuntimeStatusKeys.Paused) + findings.Add(Finding("runtime-paused", DashboardFindingSeverity.Warning, "Runtime is paused", "Runtime", "runtime", 10)); + else if (overview.Runtime.Status == DashboardRuntimeStatusKeys.Draining) + findings.Add(Finding("runtime-draining", DashboardFindingSeverity.Warning, "Runtime is draining", "Runtime", "runtime", 20)); + + if (overview.Runtime.FailedIngressSourceCount > 0) + findings.Add(Finding("ingress-source-failures", DashboardFindingSeverity.Warning, $"{overview.Runtime.FailedIngressSourceCount} ingress sources need attention", "Runtime", "runtime", 30)); + + if (overview.WorkflowInstances.Faulted > 0) + findings.Add(Finding("workflow-faults", DashboardFindingSeverity.Error, $"{overview.WorkflowInstances.Faulted} workflows faulted in the selected range", "WorkflowInstances", "faulted", 40)); + + if (overview.WorkflowInstances.Interrupted > 0) + findings.Add(Finding("workflow-interrupted", DashboardFindingSeverity.Warning, $"{overview.WorkflowInstances.Interrupted} workflows were interrupted in the selected range", "WorkflowInstances", "interrupted", 50)); + + if (overview.WorkflowInstances.IncidentBearing > 0) + findings.Add(Finding("workflow-incidents", DashboardFindingSeverity.Error, $"{overview.WorkflowInstances.IncidentBearing} workflows have incidents", "WorkflowInstances", "incidents", 60)); + + var structuredLogs = overview.Diagnostics.StructuredLogs; + if (structuredLogs.StaleSourceCount > 0) + findings.Add(Finding("structured-log-stale-sources", DashboardFindingSeverity.Warning, $"{structuredLogs.StaleSourceCount} structured log sources are stale", "StructuredLogs", "sources", 70)); + if (structuredLogs.DroppedWriteCount > 0) + findings.Add(Finding("structured-log-dropped-writes", DashboardFindingSeverity.Error, "Structured log storage dropped writes", "StructuredLogs", "storage", 80)); + if (structuredLogs.RecentErrorOrCriticalCount > 0) + findings.Add(Finding("structured-log-errors", DashboardFindingSeverity.Error, $"{structuredLogs.RecentErrorOrCriticalCount} error or critical structured logs were recorded", "StructuredLogs", "errors", 90)); + + var consoleLogs = overview.Diagnostics.ConsoleLogs; + if (consoleLogs.StaleSourceCount > 0) + findings.Add(Finding("console-log-stale-sources", DashboardFindingSeverity.Warning, $"{consoleLogs.StaleSourceCount} console log sources are stale", "ConsoleLogs", "sources", 100)); + if (consoleLogs.DroppedLineCount > 0) + findings.Add(Finding("console-log-dropped-lines", DashboardFindingSeverity.Warning, "Console log capture dropped lines", "ConsoleLogs", "dropped", 110)); + + return new() + { + Findings = findings.OrderBy(x => x.Priority).Take(Math.Clamp(take, 1, 50)).ToList(), + AppliedRange = range.Key + }; + } + + public async Task GetRecentActivityAsync(DashboardQuery query, int take, CancellationToken cancellationToken = default) + { + var range = rangeResolver.Resolve(query.Range); + var filter = CreateRangeFilter(query.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), range.From, range.To); + var order = new WorkflowInstanceOrder + { + KeySelector = x => x.UpdatedAt, + Direction = OrderDirection.Descending + }; + var page = await workflowInstanceStore.SummarizeManyAsync(filter, PageArgs.FromPage(0, Math.Clamp(take, 1, 100)), order, cancellationToken); + + return new() + { + Items = page.Items.Select(MapRecentActivity).ToList(), + AppliedRange = range.Key, + From = range.From, + To = range.To + }; + } + + public async Task GetWorkflowHotspotsAsync(DashboardWorkflowHotspotsRequest request, CancellationToken cancellationToken = default) + { + var range = rangeResolver.Resolve(request.Range); + var summaries = await workflowInstanceStore.SummarizeManyAsync(CreateRangeFilter(request.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), range.From, range.To), cancellationToken); + var metric = NormalizeHotspotMetric(request.Metric); + var hotspots = summaries + .GroupBy(x => x.DefinitionId) + .Select(x => CreateHotspot(x, metric)) + .OrderByDescending(x => x.Value) + .ThenBy(x => x.WorkflowName) + .Take(Math.Clamp(request.Take, 1, 50)) + .ToList(); + + return new() + { + Items = hotspots, + AppliedRange = range.Key, + Metric = metric, + From = range.From, + To = range.To + }; + } + + private async Task GetWorkflowMetricsAsync(DashboardRange range, bool includeSystem, CancellationToken cancellationToken) + { + var completedSummaries = (await workflowInstanceStore.SummarizeManyAsync( + CreateRangeFilter(includeSystem, nameof(WorkflowInstance.FinishedAt), range.From, range.To, subStatus: WorkflowSubStatus.Finished), + cancellationToken)).ToList(); + var durations = completedSummaries + .Where(x => x.FinishedAt != null) + .Select(x => x.FinishedAt!.Value - x.CreatedAt) + .Where(x => x >= TimeSpan.Zero) + .ToList(); + + return new() + { + Running = await CountAsync(includeSystem, status: WorkflowStatus.Running, cancellationToken: cancellationToken), + Completed = completedSummaries.Count, + Faulted = await CountAsync(includeSystem, nameof(WorkflowInstance.UpdatedAt), range.From, range.To, cancellationToken, subStatus: WorkflowSubStatus.Faulted), + Suspended = await CountAsync(includeSystem, subStatus: WorkflowSubStatus.Suspended, cancellationToken: cancellationToken), + Interrupted = await CountAsync(includeSystem, nameof(WorkflowInstance.UpdatedAt), range.From, range.To, cancellationToken, subStatus: WorkflowSubStatus.Interrupted), + IncidentBearing = await CountAsync(includeSystem, hasIncidents: true, cancellationToken: cancellationToken), + AverageDuration = durations.Count == 0 ? null : TimeSpan.FromTicks(Convert.ToInt64(durations.Average(x => x.Ticks))) + }; + } + + private DashboardRuntimeStatus GetRuntimeStatus() + { + var status = runtimeAdminService.GetStatus(); + var state = status.State; + var runtimeStatus = state.IsAcceptingNewWork + ? DashboardRuntimeStatusKeys.AcceptingWork + : state.DrainStartedAt != null + ? DashboardRuntimeStatusKeys.Draining + : DashboardRuntimeStatusKeys.Paused; + var failedSourceCount = status.Sources.Count(x => x.LastError != null); + + return new() + { + Status = runtimeStatus, + IsAcceptingWork = state.IsAcceptingNewWork, + ActiveExecutionCycleCount = status.ActiveExecutionCycleCount, + IngressSourceCount = status.Sources.Count, + FailedIngressSourceCount = failedSourceCount, + PausedAt = state.PausedAt, + DrainStartedAt = state.DrainStartedAt, + Reason = state.Reason.ToString() + }; + } + + private async Task GetDiagnosticsSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + { + var structuredLogs = await GetStructuredLogSummaryAsync(range, cancellationToken); + var consoleLogs = await GetConsoleLogSummaryAsync(range, cancellationToken); + return new() + { + StructuredLogs = structuredLogs, + ConsoleLogs = consoleLogs + }; + } + + private async Task GetStructuredLogSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + { + var provider = serviceProvider.GetService(); + if (provider == null) + return new(); + + try + { + var sources = await provider.ListSourcesAsync(cancellationToken); + var storageDiagnostics = serviceProvider.GetServices().ToList(); + var recentErrors = await provider.GetRecentAsync(new() + { + Levels = [StructuredLogLevel.Error, StructuredLogLevel.Critical], + From = range.From, + To = range.To, + Take = 1000 + }, cancellationToken); + + return new() + { + Capability = DashboardCapabilityStatus.Available, + SourceCount = sources.Count, + StaleSourceCount = sources.Count(x => x.Status == StructuredLogSourceStatus.Stale || x.Status == StructuredLogSourceStatus.Disconnected), + RecentErrorOrCriticalCount = recentErrors.Items.Count, + DroppedWriteCount = storageDiagnostics.Aggregate(0L, (total, x) => checked(total + x.DroppedWriteCount)), + DroppedEventCount = recentErrors.DroppedEvents + }; + } + catch (UnauthorizedAccessException) + { + return new() { Capability = new(DashboardCapabilityStatus.Unauthorized.Status, "No access to structured logs") }; + } + catch (Exception e) when (e is not OperationCanceledException) + { + return new() { Capability = new(DashboardCapabilityStatus.Unavailable.Status, "Structured log summary is unavailable") }; + } + } + + private async Task GetConsoleLogSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + { + var provider = serviceProvider.GetService(); + if (provider == null) + return new(); + + try + { + var sources = await provider.ListSourcesAsync(cancellationToken); + var recentStderr = await provider.GetRecentAsync(new() + { + Stream = ConsoleStream.Stderr, + From = range.From, + To = range.To, + Limit = 1000 + }, cancellationToken); + + return new() + { + Capability = DashboardCapabilityStatus.Available, + SourceCount = sources.Count, + StaleSourceCount = sources.Count(x => x.Health is ConsoleLogSourceHealth.Stale or ConsoleLogSourceHealth.Disconnected), + RecentStderrCount = recentStderr.Items.Count, + DroppedLineCount = recentStderr.Dropped.Aggregate(0L, (total, x) => checked(total + x.Count)) + }; + } + catch (UnauthorizedAccessException) + { + return new() { Capability = new(DashboardCapabilityStatus.Unauthorized.Status, "No access to console logs") }; + } + catch (Exception e) when (e is not OperationCanceledException) + { + return new() { Capability = new(DashboardCapabilityStatus.Unavailable.Status, "Console log summary is unavailable") }; + } + } + + private async Task CountAsync( + bool includeSystem, + string? timestampColumn = null, + DateTimeOffset? from = null, + DateTimeOffset? to = null, + CancellationToken cancellationToken = default, + WorkflowStatus? status = null, + WorkflowSubStatus? subStatus = null, + bool? hasIncidents = null) + { + return await workflowInstanceStore.CountAsync(CreateRangeFilter(includeSystem, timestampColumn, from, to, status, subStatus, hasIncidents), cancellationToken); + } + + private static WorkflowInstanceFilter CreateRangeFilter( + bool includeSystem, + string? timestampColumn, + DateTimeOffset? from, + DateTimeOffset? to, + WorkflowStatus? status = null, + WorkflowSubStatus? subStatus = null, + bool? hasIncidents = null) + { + var timestampFilters = new List(); + if (timestampColumn != null && from != null) + timestampFilters.Add(new() { Column = timestampColumn, Operator = TimestampFilterOperator.GreaterThanOrEqual, Timestamp = from.Value }); + if (timestampColumn != null && to != null) + timestampFilters.Add(new() { Column = timestampColumn, Operator = TimestampFilterOperator.LessThan, Timestamp = to.Value }); + + return new() + { + IsSystem = includeSystem ? null : false, + WorkflowStatus = status, + WorkflowSubStatus = subStatus, + HasIncidents = hasIncidents, + TimestampFilters = timestampFilters.Count == 0 ? null : timestampFilters + }; + } + + private static DashboardRecentActivityItem MapRecentActivity(WorkflowInstanceSummary summary) => new() + { + InstanceId = summary.Id, + DefinitionId = summary.DefinitionId, + WorkflowName = summary.Name, + Status = summary.Status.ToString(), + SubStatus = summary.SubStatus.ToString(), + IncidentCount = summary.IncidentCount, + Duration = summary.FinishedAt == null ? null : summary.FinishedAt.Value - summary.CreatedAt, + CreatedAt = summary.CreatedAt, + UpdatedAt = summary.UpdatedAt, + FinishedAt = summary.FinishedAt + }; + + private static DashboardHotspot CreateHotspot(IGrouping group, string metric) + { + var items = group.ToList(); + var durations = items + .Where(x => x.FinishedAt != null) + .Select(x => x.FinishedAt!.Value - x.CreatedAt) + .Where(x => x >= TimeSpan.Zero) + .ToList(); + var value = metric switch + { + DashboardHotspotMetric.Executions => items.Count, + DashboardHotspotMetric.Incidents => items.Sum(x => x.IncidentCount), + DashboardHotspotMetric.Duration => durations.Count == 0 ? 0 : Convert.ToInt64(durations.Average(x => x.TotalMilliseconds)), + _ => items.LongCount(x => x.SubStatus == WorkflowSubStatus.Faulted) + }; + + return new() + { + DefinitionId = group.Key, + WorkflowName = items.Select(x => x.Name).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)), + Value = value, + AverageDuration = durations.Count == 0 ? null : TimeSpan.FromTicks(Convert.ToInt64(durations.Average(x => x.Ticks))) + }; + } + + private static string NormalizeHotspotMetric(string? metric) => + metric?.Trim().ToLowerInvariant() switch + { + "executions" => DashboardHotspotMetric.Executions, + "incidents" => DashboardHotspotMetric.Incidents, + "duration" => DashboardHotspotMetric.Duration, + _ => DashboardHotspotMetric.Faults + }; + + private static DashboardFinding Finding(string id, string severity, string message, string? targetKind, string? target, int priority) => new() + { + Id = id, + Severity = severity, + Message = message, + TargetKind = targetKind, + Target = target, + Priority = priority + }; + + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; +} diff --git a/src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs b/src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs new file mode 100644 index 000000000..069e6a1ae --- /dev/null +++ b/src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs @@ -0,0 +1,20 @@ +using CShells.FastEndpoints.Features; +using CShells.Features; +using Elsa.Dashboard.Api.Extensions; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Dashboard.Api.ShellFeatures; + +[ShellFeature( + DisplayName = "Dashboard API", + Description = "Provides operational dashboard API endpoints for Elsa Studio", + DependsOn = ["ElsaFastEndpoints", "WorkflowInstances", "WorkflowRuntime"])] +[UsedImplicitly] +public class DashboardApiFeature : IFastEndpointsShellFeature +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddDashboardApiServices(); + } +} diff --git a/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs b/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs new file mode 100644 index 000000000..1f912a66b --- /dev/null +++ b/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs @@ -0,0 +1,55 @@ +using Elsa.Common; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Services; + +namespace Elsa.Dashboard.Api.UnitTests; + +public class DashboardRangeResolverTests +{ + private readonly DateTimeOffset _now = new(2026, 06, 01, 12, 00, 00, TimeSpan.Zero); + private readonly DashboardRangeResolver _resolver; + + public DashboardRangeResolverTests() + { + _resolver = new(new TestClock(_now)); + } + + [Theory] + [InlineData("1h", DashboardRangeKeys.OneHour, 1)] + [InlineData("24h", DashboardRangeKeys.TwentyFourHours, 24)] + [InlineData("7d", DashboardRangeKeys.SevenDays, 168)] + [InlineData(null, DashboardRangeKeys.TwentyFourHours, 24)] + [InlineData("unknown", DashboardRangeKeys.TwentyFourHours, 24)] + public void Resolve_ReturnsExpectedRange(string? input, string expectedKey, int expectedHours) + { + var range = _resolver.Resolve(input); + + Assert.Equal(expectedKey, range.Key); + Assert.Equal(_now, range.To); + Assert.Equal(_now.AddHours(-expectedHours), range.From); + } + + [Theory] + [InlineData(DashboardRangeKeys.OneHour, DashboardTrendGranularity.Minute)] + [InlineData(DashboardRangeKeys.TwentyFourHours, DashboardTrendGranularity.Hour)] + [InlineData(DashboardRangeKeys.SevenDays, DashboardTrendGranularity.Day)] + public void ResolveGranularity_ChoosesDefaultForRange(string range, string expectedGranularity) + { + var granularity = _resolver.ResolveGranularity(null, range); + + Assert.Equal(expectedGranularity, granularity); + } + + [Fact] + public void ResolveGranularity_PreservesExplicitGranularity() + { + var granularity = _resolver.ResolveGranularity("custom", DashboardRangeKeys.OneHour); + + Assert.Equal("custom", granularity); + } + + private class TestClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } +} diff --git a/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs b/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs new file mode 100644 index 000000000..797f8bdd4 --- /dev/null +++ b/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs @@ -0,0 +1,316 @@ +using System.Runtime.CompilerServices; +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Models; +using Elsa.Common; +using Elsa.Common.Services; +using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Api.Services; +using Elsa.Diagnostics.StructuredLogs.Contracts; +using Elsa.Diagnostics.StructuredLogs.Models; +using Elsa.Workflows; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Stores; +using Elsa.Workflows.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace Elsa.Dashboard.Api.UnitTests; + +public class DefaultDashboardProviderTests +{ + private readonly DateTimeOffset _now = new(2026, 06, 01, 12, 00, 00, TimeSpan.Zero); + private readonly MemoryWorkflowInstanceStore _workflowInstanceStore; + private readonly TestRuntimeAdminService _runtimeAdminService = new(); + + public DefaultDashboardProviderTests() + { + _workflowInstanceStore = new(new MemoryStore()); + } + + [Fact] + public async Task GetOverviewAsync_ReturnsRuntimeWorkflowAndDiagnosticsMetrics() + { + await AddInstanceAsync("running", WorkflowStatus.Running, WorkflowSubStatus.Executing, _now.AddMinutes(-30)); + await AddInstanceAsync("completed", WorkflowStatus.Finished, WorkflowSubStatus.Finished, _now.AddHours(-2), finishedAt: _now.AddHours(-1.5)); + await AddInstanceAsync("faulted", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-4), updatedAt: _now.AddHours(-3), incidentCount: 2); + await AddInstanceAsync("suspended", WorkflowStatus.Running, WorkflowSubStatus.Suspended, _now.AddHours(-5), updatedAt: _now.AddHours(-4)); + await AddInstanceAsync("system-completed", WorkflowStatus.Finished, WorkflowSubStatus.Finished, _now.AddHours(-3), finishedAt: _now.AddHours(-2), isSystem: true); + var provider = CreateProvider(services => + { + services.AddSingleton(new TestStructuredLogProvider( + [new() { Id = "structured-1", DisplayName = "Structured 1", Status = StructuredLogSourceStatus.Stale }], + [new() { Level = StructuredLogLevel.Error, SourceId = "structured-1" }], + droppedEvents: 4)); + services.AddSingleton(new TestStructuredLogStorageDiagnostics(3)); + services.AddSingleton(new TestConsoleLogProvider( + [new() { Id = "console-1", Health = ConsoleLogSourceHealth.Disconnected }], + [new() { Text = "stderr", Stream = ConsoleStream.Stderr }])); + }); + + var overview = await provider.GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours), CancellationToken.None); + + Assert.Equal("Elsa.TestHost", overview.BackendName); + Assert.Equal("Integration", overview.EnvironmentName); + Assert.Equal(DashboardRuntimeStatusKeys.AcceptingWork, overview.Runtime.Status); + Assert.Equal(2, overview.WorkflowInstances.Running); + Assert.Equal(1, overview.WorkflowInstances.Completed); + Assert.Equal(1, overview.WorkflowInstances.Faulted); + Assert.Equal(1, overview.WorkflowInstances.Suspended); + Assert.Equal(1, overview.WorkflowInstances.IncidentBearing); + Assert.Equal(TimeSpan.FromMinutes(30), overview.WorkflowInstances.AverageDuration); + Assert.Equal(DashboardCapabilityStatus.Available.Status, overview.Diagnostics.StructuredLogs.Capability.Status); + Assert.Equal(1, overview.Diagnostics.StructuredLogs.SourceCount); + Assert.Equal(1, overview.Diagnostics.StructuredLogs.StaleSourceCount); + Assert.Equal(1, overview.Diagnostics.StructuredLogs.RecentErrorOrCriticalCount); + Assert.Equal(3, overview.Diagnostics.StructuredLogs.DroppedWriteCount); + Assert.Equal(4, overview.Diagnostics.StructuredLogs.DroppedEventCount); + Assert.Equal(DashboardCapabilityStatus.Available.Status, overview.Diagnostics.ConsoleLogs.Capability.Status); + Assert.Equal(1, overview.Diagnostics.ConsoleLogs.SourceCount); + Assert.Equal(1, overview.Diagnostics.ConsoleLogs.StaleSourceCount); + Assert.Equal(1, overview.Diagnostics.ConsoleLogs.RecentStderrCount); + } + + [Fact] + public async Task GetWorkflowTrendsAsync_BucketsWorkflowActivityByRange() + { + await AddInstanceAsync("created", WorkflowStatus.Running, WorkflowSubStatus.Executing, _now.AddHours(-2), updatedAt: _now.AddHours(-1.75)); + await AddInstanceAsync("finished", WorkflowStatus.Finished, WorkflowSubStatus.Finished, _now.AddHours(-2), updatedAt: _now.AddHours(-1), finishedAt: _now.AddHours(-1)); + await AddInstanceAsync("faulted", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-3), updatedAt: _now.AddHours(-1).AddMinutes(15)); + var provider = CreateProvider(); + + var response = await provider.GetWorkflowTrendsAsync(new() + { + Range = DashboardRangeKeys.TwentyFourHours, + Granularity = DashboardTrendGranularity.Hour + }, CancellationToken.None); + + Assert.Equal(24, response.Buckets.Count); + var createdBucket = response.Buckets.Single(x => x.From == _now.AddHours(-2) && x.To == _now.AddHours(-1)); + var finishedBucket = response.Buckets.Single(x => x.From == _now.AddHours(-1) && x.To == _now); + Assert.Equal(2, createdBucket.CreatedOrStarted); + Assert.Equal(1, finishedBucket.Finished); + Assert.Equal(1, finishedBucket.Faulted); + } + + [Fact] + public async Task GetNeedsAttentionAsync_ReturnsPriorityOrderedFindings() + { + _runtimeAdminService.Status = new( + new(QuiescenceReason.AdministrativePause, _now.AddMinutes(-10), null, "maintenance", "operator", "test"), + [new("Webhook", IngressSourceState.PauseFailed, new InvalidOperationException("pause failed"), _now.AddMinutes(-5))], + 0); + await AddInstanceAsync("faulted", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-3), updatedAt: _now.AddHours(-2), incidentCount: 1); + var provider = CreateProvider(services => + { + services.AddSingleton(new TestStructuredLogProvider([], [new() { Level = StructuredLogLevel.Critical, SourceId = "structured-1" }])); + services.AddSingleton(new TestConsoleLogProvider([], [new() { Text = "stderr", Stream = ConsoleStream.Stderr }])); + }); + + var response = await provider.GetNeedsAttentionAsync(new(DashboardRangeKeys.TwentyFourHours), 4, CancellationToken.None); + + Assert.Equal(4, response.Findings.Count); + Assert.Collection(response.Findings, + finding => Assert.Equal("runtime-paused", finding.Id), + finding => Assert.Equal("ingress-source-failures", finding.Id), + finding => Assert.Equal("workflow-faults", finding.Id), + finding => Assert.Equal("workflow-incidents", finding.Id)); + } + + [Fact] + public async Task GetRecentActivityAsync_ReturnsDenseOrderedSummaries() + { + await AddInstanceAsync("old", WorkflowStatus.Finished, WorkflowSubStatus.Finished, _now.AddHours(-5), updatedAt: _now.AddHours(-4), finishedAt: _now.AddHours(-4)); + await AddInstanceAsync("newest", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-2), updatedAt: _now.AddMinutes(-5), incidentCount: 3, definitionId: "payments", name: "Payments"); + await AddInstanceAsync("middle", WorkflowStatus.Running, WorkflowSubStatus.Suspended, _now.AddHours(-3), updatedAt: _now.AddHours(-1)); + var provider = CreateProvider(); + + var response = await provider.GetRecentActivityAsync(new(DashboardRangeKeys.TwentyFourHours), 2, CancellationToken.None); + + Assert.Collection(response.Items, + item => + { + Assert.Equal("newest", item.InstanceId); + Assert.Equal("payments", item.DefinitionId); + Assert.Equal("Payments", item.WorkflowName); + Assert.Equal(nameof(WorkflowSubStatus.Faulted), item.SubStatus); + Assert.Equal(3, item.IncidentCount); + }, + item => Assert.Equal("middle", item.InstanceId)); + } + + [Fact] + public async Task GetWorkflowHotspotsAsync_GroupsByWorkflowDefinitionAndMetric() + { + await AddInstanceAsync("payments-1", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-3), updatedAt: _now.AddHours(-2), incidentCount: 2, definitionId: "payments", name: "Payments"); + await AddInstanceAsync("payments-2", WorkflowStatus.Finished, WorkflowSubStatus.Finished, _now.AddHours(-2), updatedAt: _now.AddHours(-1), finishedAt: _now.AddMinutes(-45), incidentCount: 3, definitionId: "payments", name: "Payments"); + await AddInstanceAsync("orders-1", WorkflowStatus.Finished, WorkflowSubStatus.Faulted, _now.AddHours(-2), updatedAt: _now.AddMinutes(-30), incidentCount: 1, definitionId: "orders", name: "Orders"); + var provider = CreateProvider(); + + var response = await provider.GetWorkflowHotspotsAsync(new() + { + Range = DashboardRangeKeys.TwentyFourHours, + Metric = DashboardHotspotMetric.Incidents, + Take = 2 + }, CancellationToken.None); + + Assert.Collection(response.Items, + hotspot => + { + Assert.Equal("payments", hotspot.DefinitionId); + Assert.Equal("Payments", hotspot.WorkflowName); + Assert.Equal(5, hotspot.Value); + }, + hotspot => + { + Assert.Equal("orders", hotspot.DefinitionId); + Assert.Equal(1, hotspot.Value); + }); + } + + [Fact] + public async Task GetOverviewAsync_ReturnsDiagnosticsCapabilityStates() + { + var notInstalled = await CreateProvider().GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours), CancellationToken.None); + var degraded = await CreateProvider(services => + { + services.AddSingleton(new ThrowingStructuredLogProvider(new UnauthorizedAccessException())); + services.AddSingleton(new ThrowingConsoleLogProvider(new InvalidOperationException())); + }).GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours), CancellationToken.None); + + Assert.Equal(DashboardCapabilityStatus.NotInstalled.Status, notInstalled.Diagnostics.StructuredLogs.Capability.Status); + Assert.Equal(DashboardCapabilityStatus.NotInstalled.Status, notInstalled.Diagnostics.ConsoleLogs.Capability.Status); + Assert.Equal(DashboardCapabilityStatus.Unauthorized.Status, degraded.Diagnostics.StructuredLogs.Capability.Status); + Assert.Equal(DashboardCapabilityStatus.Unavailable.Status, degraded.Diagnostics.ConsoleLogs.Capability.Status); + } + + private async Task AddInstanceAsync( + string id, + WorkflowStatus status, + WorkflowSubStatus subStatus, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt = null, + DateTimeOffset? finishedAt = null, + int incidentCount = 0, + bool isSystem = false, + string definitionId = "workflow", + string? name = null) + { + await _workflowInstanceStore.SaveAsync(new() + { + Id = id, + DefinitionId = definitionId, + DefinitionVersionId = $"{definitionId}:1", + Version = 1, + Status = status, + SubStatus = subStatus, + IncidentCount = incidentCount, + IsSystem = isSystem, + Name = name ?? definitionId, + CreatedAt = createdAt, + UpdatedAt = updatedAt ?? createdAt, + FinishedAt = finishedAt + }); + } + + private DefaultDashboardProvider CreateProvider(Action? configureServices = null) + { + var services = new ServiceCollection(); + configureServices?.Invoke(services); + return new( + _workflowInstanceStore, + _runtimeAdminService, + new(new TestClock(_now)), + services.BuildServiceProvider(), + new TestHostEnvironment()); + } + + private sealed class TestClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } + + private sealed class TestRuntimeAdminService : IWorkflowRuntimeAdminService + { + public RuntimeAdminStatus Status { get; set; } = new(QuiescenceState.Initial("test"), [], 0); + + public RuntimeAdminStatus GetStatus() => Status; + + public ValueTask PauseAsync(string? reason, string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public ValueTask ResumeAsync(string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public ValueTask ForceDrainAsync(string? reason, string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class TestStructuredLogProvider( + IReadOnlyCollection sources, + IReadOnlyCollection recentItems, + long droppedEvents = 0) : IStructuredLogProvider + { + public ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask GetRecentAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentStructuredLogsResult(recentItems, droppedEvents)); + + public async IAsyncEnumerable SubscribeAsync(StructuredLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(sources); + } + + private sealed class ThrowingStructuredLogProvider(Exception exception) : IStructuredLogProvider + { + public ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default) => throw exception; + + public ValueTask GetRecentAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => throw exception; + + public IAsyncEnumerable SubscribeAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => throw exception; + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => throw exception; + } + + private sealed class TestStructuredLogStorageDiagnostics(long droppedWriteCount) : IStructuredLogStorageDiagnostics + { + public long DroppedWriteCount { get; } = droppedWriteCount; + } + + private sealed class TestConsoleLogProvider( + IReadOnlyCollection sources, + IReadOnlyList recentItems) : IConsoleLogProvider + { + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentConsoleLogsResult { Items = recentItems }); + + public async IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(sources); + } + + private sealed class ThrowingConsoleLogProvider(Exception exception) : IConsoleLogProvider + { + public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => throw exception; + + public ValueTask GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => throw exception; + + public IAsyncEnumerable SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => throw exception; + + public ValueTask> ListSourcesAsync(CancellationToken cancellationToken = default) => throw exception; + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Integration"; + public string ApplicationName { get; set; } = "Elsa.TestHost"; + public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory(); + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/test/unit/Elsa.Dashboard.Api.UnitTests/Elsa.Dashboard.Api.UnitTests.csproj b/test/unit/Elsa.Dashboard.Api.UnitTests/Elsa.Dashboard.Api.UnitTests.csproj new file mode 100644 index 000000000..9f14e1a8b --- /dev/null +++ b/test/unit/Elsa.Dashboard.Api.UnitTests/Elsa.Dashboard.Api.UnitTests.csproj @@ -0,0 +1,11 @@ + + + + [Elsa.Dashboard.Api]* + + + + + + +