diff --git a/Elsa.sln b/Elsa.sln index 1162fd8cf..4af8b26df 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -373,6 +373,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Api", "src\m 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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Abstractions", "src\modules\Elsa.Dashboard.Abstractions\Elsa.Dashboard.Abstractions.csproj", "{27300F17-F959-4D4A-885F-FDDBE81456C1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1557,6 +1559,18 @@ Global {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 + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|x64.Build.0 = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Debug|x86.Build.0 = Debug|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|Any CPU.Build.0 = Release|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|x64.ActiveCfg = Release|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|x64.Build.0 = Release|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|x86.ActiveCfg = Release|Any CPU + {27300F17-F959-4D4A-885F-FDDBE81456C1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1693,6 +1707,7 @@ Global {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} + {27300F17-F959-4D4A-885F-FDDBE81456C1} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardContributor.cs b/src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardContributor.cs new file mode 100644 index 000000000..539db9c6d --- /dev/null +++ b/src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardContributor.cs @@ -0,0 +1,76 @@ +using Elsa.Dashboard.Abstractions.Models; + +namespace Elsa.Dashboard.Abstractions.Contracts; + +public interface IDashboardContributor +{ + string Id { get; } + + int Order { get; } + + ValueTask GetOverviewAsync(DashboardContext context) + { + return ValueTask.FromResult(null); + } + + ValueTask> GetFindingsAsync(DashboardContext context) + { + return ValueTask.FromResult>([]); + } + + ValueTask GetWorkflowTrendsAsync(DashboardTrendContext context) + { + return ValueTask.FromResult(null); + } + + ValueTask GetRecentActivityAsync(DashboardListContext context) + { + return ValueTask.FromResult(null); + } + + ValueTask GetWorkflowHotspotsAsync(DashboardHotspotsContext context) + { + return ValueTask.FromResult(null); + } +} + +public record DashboardContext( + DashboardRange Range, + bool IncludeSystem, + CancellationToken CancellationToken, + string? TenantId = null, + string? EnvironmentName = null); + +public record DashboardTrendContext( + DashboardRange Range, + string Granularity, + bool IncludeSystem, + CancellationToken CancellationToken, + string? TenantId = null, + string? EnvironmentName = null); + +public record DashboardListContext( + DashboardRange Range, + int Take, + bool IncludeSystem, + CancellationToken CancellationToken, + string? TenantId = null, + string? EnvironmentName = null); + +public record DashboardHotspotsContext( + DashboardRange Range, + string Metric, + int Take, + bool IncludeSystem, + CancellationToken CancellationToken, + string? TenantId = null, + string? EnvironmentName = null); + +public record DashboardOverviewContribution +{ + public DashboardRuntimeStatus? Runtime { get; init; } + public DashboardWorkflowInstanceMetrics? WorkflowInstances { get; init; } + public DashboardDiagnosticsSummary? Diagnostics { get; init; } + public IReadOnlyCollection Metrics { get; init; } = []; + public IReadOnlyCollection Panels { get; init; } = []; +} diff --git a/src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs b/src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardProvider.cs similarity index 89% rename from src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs rename to src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardProvider.cs index 4c5136e9a..1de017e6f 100644 --- a/src/modules/Elsa.Dashboard.Api/Contracts/IDashboardProvider.cs +++ b/src/modules/Elsa.Dashboard.Abstractions/Contracts/IDashboardProvider.cs @@ -1,6 +1,6 @@ -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Models; -namespace Elsa.Dashboard.Api.Contracts; +namespace Elsa.Dashboard.Abstractions.Contracts; public interface IDashboardProvider { diff --git a/src/modules/Elsa.Dashboard.Abstractions/Elsa.Dashboard.Abstractions.csproj b/src/modules/Elsa.Dashboard.Abstractions/Elsa.Dashboard.Abstractions.csproj new file mode 100644 index 000000000..1ab6b1be4 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Abstractions/Elsa.Dashboard.Abstractions.csproj @@ -0,0 +1,12 @@ + + + + Provides shared dashboard contracts and contribution abstractions for Elsa modules. + elsa dashboard abstractions contributors operations + + + + + + + diff --git a/src/modules/Elsa.Dashboard.Abstractions/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Dashboard.Abstractions/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..fc5913675 --- /dev/null +++ b/src/modules/Elsa.Dashboard.Abstractions/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using Elsa.Dashboard.Abstractions.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Dashboard.Abstractions.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddDashboardContributor(this IServiceCollection services) + where TContributor : class, IDashboardContributor + { + return services.AddScoped(); + } +} diff --git a/src/modules/Elsa.Dashboard.Abstractions/FodyWeavers.xml b/src/modules/Elsa.Dashboard.Abstractions/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Dashboard.Abstractions/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.Abstractions/Models/DashboardModels.cs similarity index 85% rename from src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs rename to src/modules/Elsa.Dashboard.Abstractions/Models/DashboardModels.cs index 93b6d4694..6486dd021 100644 --- a/src/modules/Elsa.Dashboard.Api/Models/DashboardModels.cs +++ b/src/modules/Elsa.Dashboard.Abstractions/Models/DashboardModels.cs @@ -1,4 +1,4 @@ -namespace Elsa.Dashboard.Api.Models; +namespace Elsa.Dashboard.Abstractions.Models; public record DashboardQuery(string? Range = null, bool IncludeSystem = false); @@ -10,6 +10,8 @@ public record DashboardOverview public DashboardRuntimeStatus Runtime { get; init; } = new(); public DashboardWorkflowInstanceMetrics WorkflowInstances { get; init; } = new(); public DashboardDiagnosticsSummary Diagnostics { get; init; } = new(); + public IReadOnlyCollection Metrics { get; init; } = []; + public IReadOnlyCollection Panels { get; init; } = []; public string AppliedRange { get; init; } = DashboardRangeKeys.TwentyFourHours; public DateTimeOffset From { get; init; } public DateTimeOffset To { get; init; } @@ -80,6 +82,35 @@ public record DashboardConsoleLogSummary public long DroppedLineCount { get; init; } } +public record DashboardMetricCard +{ + public string Id { get; init; } = null!; + public string Label { get; init; } = null!; + public string? Value { get; init; } + public string? Caption { get; init; } + public string? Icon { get; init; } + public string? Color { get; init; } + public DashboardNavigationTarget? Navigation { get; init; } + public int Order { get; init; } +} + +public record DashboardPanelSummary +{ + public string Id { get; init; } = null!; + public string Title { get; init; } = null!; + public string? Summary { get; init; } + public DashboardCapabilityStatus Capability { get; init; } = DashboardCapabilityStatus.Available; + public DashboardNavigationTarget? Navigation { get; init; } + public int Order { get; init; } +} + +public record DashboardNavigationTarget +{ + public string Kind { get; init; } = null!; + public string? Target { get; init; } + public string? Label { get; init; } +} + public record DashboardFinding { public string Id { get; init; } = null!; @@ -209,3 +240,5 @@ public static class DashboardHotspotMetric public const string Incidents = "Incidents"; public const string Duration = "Duration"; } + +public record DashboardRange(string Key, DateTimeOffset From, DateTimeOffset To); diff --git a/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj b/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj index 0cf5c4f88..b5d9def49 100644 --- a/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj +++ b/src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj @@ -6,17 +6,13 @@ - - - - - + diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs index f5aac1ed2..e982fda2f 100644 --- a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/NeedsAttention/Endpoint.cs @@ -1,6 +1,6 @@ using Elsa.Abstractions; -using Elsa.Dashboard.Api.Contracts; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Permissions; using JetBrains.Annotations; diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs index d71e95bd2..ca4026940 100644 --- a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/Overview/Endpoint.cs @@ -1,6 +1,6 @@ using Elsa.Abstractions; -using Elsa.Dashboard.Api.Contracts; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Permissions; using JetBrains.Annotations; diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs index 3305d734f..4acacb6a8 100644 --- a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/RecentActivity/Endpoint.cs @@ -1,6 +1,6 @@ using Elsa.Abstractions; -using Elsa.Dashboard.Api.Contracts; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Permissions; using JetBrains.Annotations; diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs index 955ac6b7a..3281d41d4 100644 --- a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowHotspots/Endpoint.cs @@ -1,6 +1,6 @@ using Elsa.Abstractions; -using Elsa.Dashboard.Api.Contracts; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Permissions; using JetBrains.Annotations; diff --git a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs index 2fb0479c9..b82fc1535 100644 --- a/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs +++ b/src/modules/Elsa.Dashboard.Api/Endpoints/Dashboard/WorkflowTrends/Endpoint.cs @@ -1,6 +1,6 @@ using Elsa.Abstractions; -using Elsa.Dashboard.Api.Contracts; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Permissions; using JetBrains.Annotations; diff --git a/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs index 397fc83fe..4479d87b5 100644 --- a/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Dashboard.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Elsa.Dashboard.Api.Contracts; +using Elsa.Dashboard.Abstractions.Contracts; using Elsa.Dashboard.Api.Services; using Microsoft.Extensions.DependencyInjection; diff --git a/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs b/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs index 6b357b36d..ef982239e 100644 --- a/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs +++ b/src/modules/Elsa.Dashboard.Api/Features/DashboardApiFeature.cs @@ -1,15 +1,10 @@ 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() diff --git a/src/modules/Elsa.Dashboard.Api/README.md b/src/modules/Elsa.Dashboard.Api/README.md index 59693affc..61f5b11f5 100644 --- a/src/modules/Elsa.Dashboard.Api/README.md +++ b/src/modules/Elsa.Dashboard.Api/README.md @@ -15,8 +15,8 @@ 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. +- Contributor-composed workflow instance metrics for running, completed, faulted, suspended, interrupted, incident-bearing, and average completed duration. +- Contributor-composed structured log and console log diagnostic summaries. - Applied range and resolved `from`/`to` timestamps. ### `POST /dashboard/workflow-trends` @@ -71,6 +71,87 @@ Diagnostics summaries carry a `capability` object: Dashboard overview degrades each diagnostics capability independently so a structured log failure does not prevent workflow metrics, runtime status, or console diagnostics from rendering. +## Extension Model + +Dashboard core owns the public `/dashboard/*` routes, permissions, range resolution, and contributor orchestration. Feature modules own their own dashboard data. This keeps the dependency direction open for extension: + +- `Elsa.Dashboard.Api` references `Elsa.Dashboard.Abstractions`. +- Feature modules reference `Elsa.Dashboard.Abstractions` and register one or more `IDashboardContributor` implementations. +- Dashboard core does not reference workflow, diagnostics, or future feature modules. + +`IDashboardContributor` is intentionally broad enough for a module to contribute only the surfaces it owns: + +- `GetOverviewAsync` for metric cards, panel summaries, runtime status, workflow metrics, and diagnostic summary slices. +- `GetFindingsAsync` for priority-ordered findings. +- `GetWorkflowTrendsAsync` for trend buckets. +- `GetRecentActivityAsync` for compact activity rows. +- `GetWorkflowHotspotsAsync` for hotspot rows. + +Contributor failures are isolated by the dashboard composer. A failed contributor does not break the whole dashboard response; request cancellation is still honored. + +### Backend Weather Example + +```csharp +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Extensions; +using Elsa.Dashboard.Abstractions.Models; + +public class WeatherDashboardContributor(IWeatherService weatherService) : IDashboardContributor +{ + public string Id => "weather"; + public int Order => 500; + + public async ValueTask GetOverviewAsync(DashboardContext context) + { + var forecast = await weatherService.GetForecastAsync(context.CancellationToken); + + return new() + { + Panels = + [ + new() + { + Id = "weather.current", + Title = "Weather", + Summary = forecast.Summary, + Order = 10, + Navigation = new() { Kind = "Weather", Target = "current" } + } + ] + }; + } +} + +services.AddDashboardContributor(); +``` + +The example belongs in a hypothetical `Elsa.Weather.Dashboard` module or inside an existing Weather module, not in `Elsa.Dashboard.Api`. + +### Studio Widget Model + +Studio follows the same dependency direction. `Elsa.Studio.Dashboard` owns the dashboard route, refresh/range state, zone rendering, shared `DashboardWidgetContext`, and registration helpers. Feature modules register widgets into zones such as metrics, findings, primary panels, secondary panels, and diagnostics/status. + +Minimal Studio Weather widget registration: + +```csharp +services.AddDashboardWidget( + "weather.current", + DashboardWidgetZones.SecondaryPanels, + order: 500, + title: "Weather", + payloadKind: "Weather"); +``` + +`WeatherDashboardWidget` can read the shared dashboard snapshot and navigation services from `DashboardWidgetContext`. The widget should live in `Elsa.Studio.Weather.Dashboard` or the Studio Weather module, not in `Elsa.Studio.Dashboard`. + +### Diagnostics Migration + +Structured-log and console-log dashboard behavior is diagnostics-owned: + +- Backend summaries and findings are contributed by the corresponding diagnostics modules. +- Studio widgets are registered by the corresponding diagnostics Studio modules. +- Installing Dashboard alone does not install diagnostics. Installing diagnostics plus Dashboard causes diagnostics summaries and widgets to appear. + ## 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. diff --git a/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs b/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs index b81d554cd..5728d8bff 100644 --- a/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs +++ b/src/modules/Elsa.Dashboard.Api/Services/DashboardRangeResolver.cs @@ -1,5 +1,5 @@ using Elsa.Common; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Models; namespace Elsa.Dashboard.Api.Services; @@ -43,5 +43,3 @@ public class DashboardRangeResolver(ISystemClock clock) _ => 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 index 49f6f4507..73072caa0 100644 --- a/src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs +++ b/src/modules/Elsa.Dashboard.Api/Services/DefaultDashboardProvider.cs @@ -1,44 +1,36 @@ -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 Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; using Microsoft.Extensions.Hosting; namespace Elsa.Dashboard.Api.Services; public class DefaultDashboardProvider( - IWorkflowInstanceStore workflowInstanceStore, - IWorkflowRuntimeAdminService runtimeAdminService, + IEnumerable contributors, 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); + var context = CreateContext(range, query.IncludeSystem, cancellationToken); + var contributions = new List(); + + foreach (var contributor in OrderedContributors) + { + var contribution = await ExecuteContributorAsync(contributor, x => x.GetOverviewAsync(context).AsTask(), cancellationToken); + if (contribution != null) + contributions.Add(contribution); + } return new() { BackendName = environment.ApplicationName, EnvironmentName = environment.EnvironmentName, - Runtime = runtime, - WorkflowInstances = workflowMetrics, - Diagnostics = diagnostics, + Runtime = MergeRuntime(contributions), + WorkflowInstances = MergeWorkflowMetrics(contributions), + Diagnostics = MergeDiagnostics(contributions), + Metrics = contributions.SelectMany(x => x.Metrics).OrderBy(x => x.Order).ThenBy(x => x.Id, StringComparer.Ordinal).ToList(), + Panels = contributions.SelectMany(x => x.Panels).OrderBy(x => x.Order).ThenBy(x => x.Id, StringComparer.Ordinal).ToList(), AppliedRange = range.Key, From = range.From, To = range.To @@ -49,23 +41,23 @@ public class DefaultDashboardProvider( { 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() + var context = new DashboardTrendContext(range, granularity, request.IncludeSystem, cancellationToken, EnvironmentName: environment.EnvironmentName); + var responses = await CollectAsync(contributor => contributor.GetWorkflowTrendsAsync(context).AsTask(), cancellationToken); + var buckets = responses + .SelectMany(x => x.Buckets) + .GroupBy(x => new { x.From, x.To }) + .Select(x => new DashboardTrendBucket { - 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) - }); - } + From = x.Key.From, + To = x.Key.To, + CreatedOrStarted = x.Sum(y => y.CreatedOrStarted), + Finished = x.Sum(y => y.Finished), + Faulted = x.Sum(y => y.Faulted), + Suspended = x.Sum(y => y.Suspended), + IncidentBearing = x.Sum(y => y.IncidentBearing) + }) + .OrderBy(x => x.From) + .ToList(); return new() { @@ -80,43 +72,16 @@ public class DefaultDashboardProvider( 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)); + var context = CreateContext(range, query.IncludeSystem, cancellationToken); + var findings = await CollectManyAsync(contributor => contributor.GetFindingsAsync(context).AsTask(), cancellationToken); return new() { - Findings = findings.OrderBy(x => x.Priority).Take(Math.Clamp(take, 1, 50)).ToList(), + Findings = findings + .OrderBy(x => x.Priority) + .ThenBy(x => x.Id, StringComparer.Ordinal) + .Take(Math.Clamp(take, 1, 50)) + .ToList(), AppliedRange = range.Key }; } @@ -124,17 +89,18 @@ public class DefaultDashboardProvider( 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); + var context = new DashboardListContext(range, Math.Clamp(take, 1, 100), query.IncludeSystem, cancellationToken, EnvironmentName: environment.EnvironmentName); + var responses = await CollectAsync(contributor => contributor.GetRecentActivityAsync(context).AsTask(), cancellationToken); + var items = responses + .SelectMany(x => x.Items) + .OrderByDescending(x => x.UpdatedAt ?? x.FinishedAt ?? x.CreatedAt) + .ThenBy(x => x.InstanceId, StringComparer.Ordinal) + .Take(context.Take) + .ToList(); return new() { - Items = page.Items.Select(MapRecentActivity).ToList(), + Items = items, AppliedRange = range.Key, From = range.From, To = range.To @@ -144,19 +110,28 @@ public class DefaultDashboardProvider( 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 + var take = Math.Clamp(request.Take, 1, 50); + var context = new DashboardHotspotsContext(range, metric, take, request.IncludeSystem, cancellationToken, EnvironmentName: environment.EnvironmentName); + var responses = await CollectAsync(contributor => contributor.GetWorkflowHotspotsAsync(context).AsTask(), cancellationToken); + var items = responses + .SelectMany(x => x.Items) .GroupBy(x => x.DefinitionId) - .Select(x => CreateHotspot(x, metric)) + .Select(x => new DashboardHotspot + { + DefinitionId = x.Key, + WorkflowName = x.Select(y => y.WorkflowName).FirstOrDefault(y => !string.IsNullOrWhiteSpace(y)), + Value = x.Sum(y => y.Value), + AverageDuration = AverageDuration(x.Select(y => y.AverageDuration)) + }) .OrderByDescending(x => x.Value) - .ThenBy(x => x.WorkflowName) - .Take(Math.Clamp(request.Take, 1, 50)) + .ThenBy(x => x.WorkflowName, StringComparer.Ordinal) + .Take(take) .ToList(); return new() { - Items = hotspots, + Items = items, AppliedRange = range.Key, Metric = metric, From = range.From, @@ -164,215 +139,102 @@ public class DefaultDashboardProvider( }; } - 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) + private IReadOnlyCollection OrderedContributors => + contributors + .OrderBy(x => x.Order) + .ThenBy(x => x.Id, StringComparer.Ordinal) .ToList(); - return new() + private DashboardContext CreateContext(DashboardRange range, bool includeSystem, CancellationToken cancellationToken) => + new(range, includeSystem, cancellationToken, EnvironmentName: environment.EnvironmentName); + + private async Task> CollectAsync( + Func> action, + CancellationToken cancellationToken) + where T : class + { + var results = new List(); + foreach (var contributor in OrderedContributors) { - 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))) - }; + var result = await ExecuteContributorAsync(contributor, action, cancellationToken); + if (result != null) + results.Add(result); + } + + return results; } - private DashboardRuntimeStatus GetRuntimeStatus() + private async Task> CollectManyAsync( + Func>> action, + CancellationToken cancellationToken) { - 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() + var results = new List(); + foreach (var contributor in OrderedContributors) { - Status = runtimeStatus, - IsAcceptingWork = state.IsAcceptingNewWork, - ActiveExecutionCycleCount = status.ActiveExecutionCycleCount, - IngressSourceCount = status.Sources.Count, - FailedIngressSourceCount = failedSourceCount, - PausedAt = state.PausedAt, - DrainStartedAt = state.DrainStartedAt, - Reason = state.Reason.ToString() - }; + var result = await ExecuteContributorAsync(contributor, action, cancellationToken); + if (result != null) + results.AddRange(result); + } + + return results; } - private async Task GetDiagnosticsSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + private static async Task ExecuteContributorAsync( + IDashboardContributor contributor, + Func> action, + 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 - }; + return await action(contributor); } - catch (UnauthorizedAccessException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - return new() { Capability = new(DashboardCapabilityStatus.Unauthorized.Status, "No access to structured logs") }; + throw; } - catch (Exception e) when (e is not OperationCanceledException) + catch { - return new() { Capability = new(DashboardCapabilityStatus.Unavailable.Status, "Structured log summary is unavailable") }; + return default; } } - private async Task GetConsoleLogSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + private static DashboardRuntimeStatus MergeRuntime(IEnumerable contributions) => + contributions + .Select(x => x.Runtime) + .FirstOrDefault(x => x != null && x.Status != DashboardRuntimeStatusKeys.Unavailable) + ?? new(); + + private static DashboardWorkflowInstanceMetrics MergeWorkflowMetrics(IEnumerable contributions) { - 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 }); - + var metrics = contributions.Select(x => x.WorkflowInstances).OfType().ToList(); return new() { - IsSystem = includeSystem ? null : false, - WorkflowStatus = status, - WorkflowSubStatus = subStatus, - HasIncidents = hasIncidents, - TimestampFilters = timestampFilters.Count == 0 ? null : timestampFilters + Running = metrics.Sum(x => x.Running), + Completed = metrics.Sum(x => x.Completed), + Faulted = metrics.Sum(x => x.Faulted), + Suspended = metrics.Sum(x => x.Suspended), + Interrupted = metrics.Sum(x => x.Interrupted), + IncidentBearing = metrics.Sum(x => x.IncidentBearing), + AverageDuration = AverageDuration(metrics.Select(x => x.AverageDuration)) }; } - private static DashboardRecentActivityItem MapRecentActivity(WorkflowInstanceSummary summary) => new() + private static DashboardDiagnosticsSummary MergeDiagnostics(IEnumerable contributions) { - 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) - }; - + var summaries = contributions.Select(x => x.Diagnostics).OfType().ToList(); 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))) + StructuredLogs = summaries.Select(x => x.StructuredLogs).FirstOrDefault(x => x.Capability.Status != DashboardCapabilityStatus.NotInstalled.Status) ?? new(), + ConsoleLogs = summaries.Select(x => x.ConsoleLogs).FirstOrDefault(x => x.Capability.Status != DashboardCapabilityStatus.NotInstalled.Status) ?? new() }; } + private static TimeSpan? AverageDuration(IEnumerable durations) + { + var values = durations.OfType().Where(x => x >= TimeSpan.Zero).ToList(); + return values.Count == 0 ? null : TimeSpan.FromTicks(Convert.ToInt64(values.Average(x => x.Ticks))); + } + private static string NormalizeHotspotMetric(string? metric) => metric?.Trim().ToLowerInvariant() switch { @@ -381,16 +243,4 @@ public class DefaultDashboardProvider( "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 index 069e6a1ae..910415781 100644 --- a/src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs +++ b/src/modules/Elsa.Dashboard.Api/ShellFeatures/DashboardApiFeature.cs @@ -9,7 +9,7 @@ namespace Elsa.Dashboard.Api.ShellFeatures; [ShellFeature( DisplayName = "Dashboard API", Description = "Provides operational dashboard API endpoints for Elsa Studio", - DependsOn = ["ElsaFastEndpoints", "WorkflowInstances", "WorkflowRuntime"])] + DependsOn = ["ElsaFastEndpoints"])] [UsedImplicitly] public class DashboardApiFeature : IFastEndpointsShellFeature { diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Dashboard/ConsoleLogsDashboardContributor.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Dashboard/ConsoleLogsDashboardContributor.cs new file mode 100644 index 000000000..b7a371586 --- /dev/null +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Dashboard/ConsoleLogsDashboardContributor.cs @@ -0,0 +1,85 @@ +using ConsoleLogStreaming.Core; +using ConsoleLogStreaming.Core.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; + +namespace Elsa.Diagnostics.ConsoleLogs.Dashboard; + +public class ConsoleLogsDashboardContributor(IConsoleLogProvider provider) : IDashboardContributor +{ + public string Id => "diagnostics.console-logs"; + + public int Order => 400; + + public async ValueTask GetOverviewAsync(DashboardContext context) + { + var summary = await GetSummaryAsync(context.Range, context.CancellationToken); + return new() + { + Diagnostics = new() + { + ConsoleLogs = summary + } + }; + } + + public async ValueTask> GetFindingsAsync(DashboardContext context) + { + var summary = await GetSummaryAsync(context.Range, context.CancellationToken); + var findings = new List(); + + if (summary.Capability.Status == DashboardCapabilityStatus.Unauthorized.Status) + findings.Add(Finding("console-log-unauthorized", DashboardFindingSeverity.Warning, "Console log dashboard data is not accessible", "ConsoleLogs", "access", 100)); + else if (summary.Capability.Status == DashboardCapabilityStatus.Unavailable.Status) + findings.Add(Finding("console-log-unavailable", DashboardFindingSeverity.Warning, "Console log dashboard data is unavailable", "ConsoleLogs", "status", 100)); + + if (summary.StaleSourceCount > 0) + findings.Add(Finding("console-log-stale-sources", DashboardFindingSeverity.Warning, $"{summary.StaleSourceCount} console log sources are stale", "ConsoleLogs", "sources", 100)); + if (summary.DroppedLineCount > 0) + findings.Add(Finding("console-log-dropped-lines", DashboardFindingSeverity.Warning, "Console log capture dropped lines", "ConsoleLogs", "dropped", 110)); + + return findings; + } + + private async Task GetSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + { + 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 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 + }; +} diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj index 208c80ab4..9ad94d27a 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Elsa.Diagnostics.ConsoleLogs.csproj @@ -16,6 +16,7 @@ + diff --git a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs index c0dde3cac..720498e52 100644 --- a/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Diagnostics.ConsoleLogs/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using ConsoleLogStreaming.Core.DependencyInjection; using ConsoleLogStreaming.Core.Options; using CShells.Lifecycle; +using Elsa.Dashboard.Abstractions.Extensions; +using Elsa.Diagnostics.ConsoleLogs.Dashboard; using Elsa.Diagnostics.ConsoleLogs.RealTime; using Elsa.Diagnostics.ConsoleLogs.Services; using Microsoft.Extensions.DependencyInjection; @@ -28,6 +30,7 @@ public static class ServiceCollectionExtensions services.TryAddScoped(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.AddDashboardContributor(); return services; } diff --git a/src/modules/Elsa.Diagnostics.StructuredLogs/Dashboard/StructuredLogsDashboardContributor.cs b/src/modules/Elsa.Diagnostics.StructuredLogs/Dashboard/StructuredLogsDashboardContributor.cs new file mode 100644 index 000000000..879c4016e --- /dev/null +++ b/src/modules/Elsa.Diagnostics.StructuredLogs/Dashboard/StructuredLogsDashboardContributor.cs @@ -0,0 +1,90 @@ +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; +using Elsa.Diagnostics.StructuredLogs.Contracts; +using Elsa.Diagnostics.StructuredLogs.Models; + +namespace Elsa.Diagnostics.StructuredLogs.Dashboard; + +public class StructuredLogsDashboardContributor( + IStructuredLogProvider provider, + IEnumerable storageDiagnostics) : IDashboardContributor +{ + public string Id => "diagnostics.structured-logs"; + + public int Order => 300; + + public async ValueTask GetOverviewAsync(DashboardContext context) + { + var summary = await GetSummaryAsync(context.Range, context.CancellationToken); + return new() + { + Diagnostics = new() + { + StructuredLogs = summary + } + }; + } + + public async ValueTask> GetFindingsAsync(DashboardContext context) + { + var summary = await GetSummaryAsync(context.Range, context.CancellationToken); + var findings = new List(); + + if (summary.Capability.Status == DashboardCapabilityStatus.Unauthorized.Status) + findings.Add(Finding("structured-log-unauthorized", DashboardFindingSeverity.Warning, "Structured log dashboard data is not accessible", "StructuredLogs", "access", 70)); + else if (summary.Capability.Status == DashboardCapabilityStatus.Unavailable.Status) + findings.Add(Finding("structured-log-unavailable", DashboardFindingSeverity.Warning, "Structured log dashboard data is unavailable", "StructuredLogs", "status", 70)); + + if (summary.StaleSourceCount > 0) + findings.Add(Finding("structured-log-stale-sources", DashboardFindingSeverity.Warning, $"{summary.StaleSourceCount} structured log sources are stale", "StructuredLogs", "sources", 70)); + if (summary.DroppedWriteCount > 0) + findings.Add(Finding("structured-log-dropped-writes", DashboardFindingSeverity.Error, "Structured log storage dropped writes", "StructuredLogs", "storage", 80)); + if (summary.RecentErrorOrCriticalCount > 0) + findings.Add(Finding("structured-log-errors", DashboardFindingSeverity.Error, $"{summary.RecentErrorOrCriticalCount} error or critical structured logs were recorded", "StructuredLogs", "errors", 90)); + + return findings; + } + + private async Task GetSummaryAsync(DashboardRange range, CancellationToken cancellationToken) + { + try + { + var sources = await provider.ListSourcesAsync(cancellationToken); + 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 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 + }; +} diff --git a/src/modules/Elsa.Diagnostics.StructuredLogs/Elsa.Diagnostics.StructuredLogs.csproj b/src/modules/Elsa.Diagnostics.StructuredLogs/Elsa.Diagnostics.StructuredLogs.csproj index 7229a465e..d9221876d 100644 --- a/src/modules/Elsa.Diagnostics.StructuredLogs/Elsa.Diagnostics.StructuredLogs.csproj +++ b/src/modules/Elsa.Diagnostics.StructuredLogs/Elsa.Diagnostics.StructuredLogs.csproj @@ -14,6 +14,7 @@ + diff --git a/src/modules/Elsa.Diagnostics.StructuredLogs/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Diagnostics.StructuredLogs/Extensions/ServiceCollectionExtensions.cs index fb8f3a6c3..7c57cddcc 100644 --- a/src/modules/Elsa.Diagnostics.StructuredLogs/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Diagnostics.StructuredLogs/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using Elsa.Dashboard.Abstractions.Extensions; +using Elsa.Diagnostics.StructuredLogs.Dashboard; using Elsa.Diagnostics.StructuredLogs.Contracts; using Elsa.Diagnostics.StructuredLogs.Logging; using Elsa.Diagnostics.StructuredLogs.Options; @@ -28,6 +30,7 @@ public static class ServiceCollectionExtensions services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.AddDashboardContributor(); return services; } diff --git a/src/modules/Elsa.Workflows.Runtime/Dashboard/WorkflowDashboardContributor.cs b/src/modules/Elsa.Workflows.Runtime/Dashboard/WorkflowDashboardContributor.cs new file mode 100644 index 000000000..52a24188a --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Dashboard/WorkflowDashboardContributor.cs @@ -0,0 +1,268 @@ +using Elsa.Common.Entities; +using Elsa.Common.Models; +using Elsa.Dashboard.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.Models; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Entities; +using Elsa.Workflows.Management.Enums; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Management.Models; + +namespace Elsa.Workflows.Runtime.Dashboard; + +public class WorkflowDashboardContributor( + IWorkflowInstanceStore workflowInstanceStore, + IWorkflowRuntimeAdminService runtimeAdminService) : IDashboardContributor +{ + public string Id => "workflows"; + + public int Order => 100; + + public async ValueTask GetOverviewAsync(DashboardContext context) + { + return new() + { + Runtime = GetRuntimeStatus(), + WorkflowInstances = await GetWorkflowMetricsAsync(context.Range, context.IncludeSystem, context.CancellationToken) + }; + } + + public async ValueTask> GetFindingsAsync(DashboardContext context) + { + var runtime = GetRuntimeStatus(); + var workflowMetrics = await GetWorkflowMetricsAsync(context.Range, context.IncludeSystem, context.CancellationToken); + var findings = new List(); + + if (runtime.Status == DashboardRuntimeStatusKeys.Paused) + findings.Add(Finding("runtime-paused", DashboardFindingSeverity.Warning, "Runtime is paused", "Runtime", "runtime", 10)); + else if (runtime.Status == DashboardRuntimeStatusKeys.Draining) + findings.Add(Finding("runtime-draining", DashboardFindingSeverity.Warning, "Runtime is draining", "Runtime", "runtime", 20)); + + if (runtime.FailedIngressSourceCount > 0) + findings.Add(Finding("ingress-source-failures", DashboardFindingSeverity.Warning, $"{runtime.FailedIngressSourceCount} ingress sources need attention", "Runtime", "runtime", 30)); + + if (workflowMetrics.Faulted > 0) + findings.Add(Finding("workflow-faults", DashboardFindingSeverity.Error, $"{workflowMetrics.Faulted} workflows faulted in the selected range", "WorkflowInstances", "faulted", 40)); + + if (workflowMetrics.Interrupted > 0) + findings.Add(Finding("workflow-interrupted", DashboardFindingSeverity.Warning, $"{workflowMetrics.Interrupted} workflows were interrupted in the selected range", "WorkflowInstances", "interrupted", 50)); + + if (workflowMetrics.IncidentBearing > 0) + findings.Add(Finding("workflow-incidents", DashboardFindingSeverity.Error, $"{workflowMetrics.IncidentBearing} workflows have incidents", "WorkflowInstances", "incidents", 60)); + + return findings; + } + + public async ValueTask GetWorkflowTrendsAsync(DashboardTrendContext context) + { + var bucketSize = GetBucketSize(context.Granularity); + var buckets = new List(); + + for (var bucketFrom = context.Range.From; bucketFrom < context.Range.To; bucketFrom = bucketFrom.Add(bucketSize)) + { + var bucketTo = Min(bucketFrom.Add(bucketSize), context.Range.To); + buckets.Add(new() + { + From = bucketFrom, + To = bucketTo, + CreatedOrStarted = await CountAsync(context.IncludeSystem, nameof(WorkflowInstance.CreatedAt), bucketFrom, bucketTo, context.CancellationToken), + Finished = await CountAsync(context.IncludeSystem, nameof(WorkflowInstance.FinishedAt), bucketFrom, bucketTo, context.CancellationToken, subStatus: WorkflowSubStatus.Finished), + Faulted = await CountAsync(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, context.CancellationToken, subStatus: WorkflowSubStatus.Faulted), + Suspended = await CountAsync(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, context.CancellationToken, subStatus: WorkflowSubStatus.Suspended), + IncidentBearing = await CountAsync(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), bucketFrom, bucketTo, context.CancellationToken, hasIncidents: true) + }); + } + + return new() + { + Buckets = buckets, + AppliedRange = context.Range.Key, + Granularity = context.Granularity, + From = context.Range.From, + To = context.Range.To + }; + } + + public async ValueTask GetRecentActivityAsync(DashboardListContext context) + { + var filter = CreateRangeFilter(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), context.Range.From, context.Range.To); + var order = new WorkflowInstanceOrder + { + KeySelector = x => x.UpdatedAt, + Direction = OrderDirection.Descending + }; + var page = await workflowInstanceStore.SummarizeManyAsync(filter, PageArgs.FromPage(0, context.Take), order, context.CancellationToken); + + return new() + { + Items = page.Items.Select(MapRecentActivity).ToList(), + AppliedRange = context.Range.Key, + From = context.Range.From, + To = context.Range.To + }; + } + + public async ValueTask GetWorkflowHotspotsAsync(DashboardHotspotsContext context) + { + var summaries = await workflowInstanceStore.SummarizeManyAsync(CreateRangeFilter(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), context.Range.From, context.Range.To), context.CancellationToken); + var hotspots = summaries + .GroupBy(x => x.DefinitionId) + .Select(x => CreateHotspot(x, context.Metric)) + .OrderByDescending(x => x.Value) + .ThenBy(x => x.WorkflowName) + .Take(context.Take) + .ToList(); + + return new() + { + Items = hotspots, + AppliedRange = context.Range.Key, + Metric = context.Metric, + From = context.Range.From, + To = context.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 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 TimeSpan GetBucketSize(string granularity) => + granularity.Equals(DashboardTrendGranularity.Minute, StringComparison.OrdinalIgnoreCase) + ? TimeSpan.FromMinutes(1) + : granularity.Equals(DashboardTrendGranularity.Day, StringComparison.OrdinalIgnoreCase) + ? TimeSpan.FromDays(1) + : TimeSpan.FromHours(1); + + 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.Workflows.Runtime/Elsa.Workflows.Runtime.csproj b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj index f089e7d64..0333ac8cb 100644 --- a/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj +++ b/src/modules/Elsa.Workflows.Runtime/Elsa.Workflows.Runtime.csproj @@ -17,6 +17,7 @@ + diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 9d50fe713..a176e7be8 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -32,6 +32,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Elsa.Common.Serialization; +using Elsa.Dashboard.Abstractions.Extensions; +using Elsa.Workflows.Runtime.Dashboard; namespace Elsa.Workflows.Runtime.Features; @@ -298,6 +300,7 @@ public class WorkflowRuntimeFeature(IModule module) : FeatureBase(module) // Domain service that backs all runtime-admin transports (US2). Encapsulates the audit-on-effective- // transition rule (SC-007) so transports stay thin. Scoped because INotificationSender is scoped. .AddScoped() + .AddDashboardContributor() // Interrupted-workflow recovery on shell activation (US3). Disjoint from the timeout-based // RestartInterruptedWorkflowsTask: filter is SubStatus = Interrupted; that task's filter is IsExecuting=true. .AddScoped() diff --git a/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs b/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs index 1f912a66b..f2c89ab47 100644 --- a/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs +++ b/test/unit/Elsa.Dashboard.Api.UnitTests/DashboardRangeResolverTests.cs @@ -1,5 +1,5 @@ using Elsa.Common; -using Elsa.Dashboard.Api.Models; +using Elsa.Dashboard.Abstractions.Models; using Elsa.Dashboard.Api.Services; namespace Elsa.Dashboard.Api.UnitTests; diff --git a/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs b/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs index 797f8bdd4..8d8b579a5 100644 --- a/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs +++ b/test/unit/Elsa.Dashboard.Api.UnitTests/DefaultDashboardProviderTests.cs @@ -1,18 +1,7 @@ -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.Abstractions.Contracts; +using Elsa.Dashboard.Abstractions.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; @@ -21,209 +10,268 @@ 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() + public async Task GetOverviewAsync_WithNoContributors_ReturnsStableEmptySnapshot() { - 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 provider = CreateProvider(); - var overview = await provider.GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours), CancellationToken.None); + var overview = await provider.GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours)); Assert.Equal("Elsa.TestHost", overview.BackendName); Assert.Equal("Integration", overview.EnvironmentName); + Assert.Equal(DashboardRuntimeStatusKeys.Unavailable, overview.Runtime.Status); + Assert.Equal(0, overview.WorkflowInstances.Running); + Assert.Equal(DashboardCapabilityStatus.NotInstalled.Status, overview.Diagnostics.StructuredLogs.Capability.Status); + Assert.Equal(DashboardCapabilityStatus.NotInstalled.Status, overview.Diagnostics.ConsoleLogs.Capability.Status); + Assert.Empty(overview.Metrics); + Assert.Empty(overview.Panels); + } + + [Fact] + public async Task GetOverviewAsync_ComposesWorkflowAndDiagnosticsContributors() + { + var provider = CreateProvider( + new TestContributor("diagnostics", 200) + { + Overview = new() + { + Diagnostics = new() + { + StructuredLogs = new() + { + Capability = DashboardCapabilityStatus.Available, + SourceCount = 2 + } + } + } + }, + new TestContributor("workflows", 100) + { + Overview = new() + { + Runtime = new() + { + Status = DashboardRuntimeStatusKeys.AcceptingWork, + IsAcceptingWork = true + }, + WorkflowInstances = new() + { + Running = 3, + Completed = 5, + Faulted = 1 + } + } + }); + + var overview = await provider.GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours)); + Assert.Equal(DashboardRuntimeStatusKeys.AcceptingWork, overview.Runtime.Status); - Assert.Equal(2, overview.WorkflowInstances.Running); - Assert.Equal(1, overview.WorkflowInstances.Completed); + Assert.Equal(3, overview.WorkflowInstances.Running); + Assert.Equal(5, 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); + Assert.Equal(2, overview.Diagnostics.StructuredLogs.SourceCount); } [Fact] - public async Task GetWorkflowTrendsAsync_BucketsWorkflowActivityByRange() + public async Task GetNeedsAttentionAsync_ReturnsContributorFindingsInDeterministicOrder() { - 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 provider = CreateProvider( + new TestContributor("b", 20) + { + Findings = + [ + new() { Id = "second-b", Message = "Second B", Priority = 20 }, + new() { Id = "second-a", Message = "Second A", Priority = 20 } + ] + }, + new TestContributor("a", 10) + { + Findings = + [ + new() { Id = "first", Message = "First", Priority = 10 } + ] + }); - var response = await provider.GetWorkflowTrendsAsync(new() - { - Range = DashboardRangeKeys.TwentyFourHours, - Granularity = DashboardTrendGranularity.Hour - }, CancellationToken.None); + var response = await provider.GetNeedsAttentionAsync(new(DashboardRangeKeys.TwentyFourHours), 10); - 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)); + finding => Assert.Equal("first", finding.Id), + finding => Assert.Equal("second-a", finding.Id), + finding => Assert.Equal("second-b", finding.Id)); } [Fact] - public async Task GetRecentActivityAsync_ReturnsDenseOrderedSummaries() + public async Task ContributorFailure_DoesNotBreakDashboard() { - 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 provider = CreateProvider( + new ThrowingContributor("broken", 1), + new TestContributor("healthy", 2) + { + Overview = new() + { + WorkflowInstances = new() + { + Running = 7 + } + }, + Findings = + [ + new() { Id = "healthy", Message = "Healthy", Priority = 10 } + ] + }); - var response = await provider.GetRecentActivityAsync(new(DashboardRangeKeys.TwentyFourHours), 2, CancellationToken.None); + var overview = await provider.GetOverviewAsync(new(DashboardRangeKeys.TwentyFourHours)); + var needsAttention = await provider.GetNeedsAttentionAsync(new(DashboardRangeKeys.TwentyFourHours), 10); + + Assert.Equal(7, overview.WorkflowInstances.Running); + Assert.Single(needsAttention.Findings); + } + + [Fact] + public async Task RequestCancellation_IsNotSwallowed() + { + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + var provider = CreateProvider(new CanceledContributor()); + + await Assert.ThrowsAsync(() => provider.GetOverviewAsync(new(), cancellationTokenSource.Token)); + } + + [Fact] + public async Task GetWorkflowTrendsAsync_AggregatesContributorBuckets() + { + var from = _now.AddHours(-1); + var provider = CreateProvider( + new TestContributor("a", 1) + { + Trend = new() + { + Buckets = [new() { From = from, To = _now, CreatedOrStarted = 1, Faulted = 2 }] + } + }, + new TestContributor("b", 2) + { + Trend = new() + { + Buckets = [new() { From = from, To = _now, CreatedOrStarted = 3, Finished = 4 }] + } + }); + + var response = await provider.GetWorkflowTrendsAsync(new() { Range = DashboardRangeKeys.OneHour, Granularity = DashboardTrendGranularity.Hour }); + var bucket = Assert.Single(response.Buckets); + + Assert.Equal(4, bucket.CreatedOrStarted); + Assert.Equal(4, bucket.Finished); + Assert.Equal(2, bucket.Faulted); + } + + [Fact] + public async Task GetRecentActivityAsync_MergesAndLimitsContributorItems() + { + var provider = CreateProvider( + new TestContributor("a", 1) + { + RecentActivity = new() + { + Items = [Recent("old", _now.AddMinutes(-10)), Recent("new", _now)] + } + }, + new TestContributor("b", 2) + { + RecentActivity = new() + { + Items = [Recent("middle", _now.AddMinutes(-5))] + } + }); + + var response = await provider.GetRecentActivityAsync(new(DashboardRangeKeys.OneHour), 2); 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("new", item.InstanceId), item => Assert.Equal("middle", item.InstanceId)); } [Fact] - public async Task GetWorkflowHotspotsAsync_GroupsByWorkflowDefinitionAndMetric() + public void DashboardApiProject_DoesNotReferenceWorkflowOrDiagnosticsModules() { - 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 projectFile = FindRepositoryRoot().Combine("src/modules/Elsa.Dashboard.Api/Elsa.Dashboard.Api.csproj"); + var project = File.ReadAllText(projectFile); - 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); - }); + Assert.DoesNotContain("Elsa.Workflows", project); + Assert.DoesNotContain("Elsa.Diagnostics", project); + Assert.Contains("Elsa.Dashboard.Abstractions", project); } - [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); + private DefaultDashboardProvider CreateProvider(params IDashboardContributor[] contributors) => + new(contributors, new(new TestClock(_now)), new TestHostEnvironment()); - 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 static DashboardRecentActivityItem Recent(string id, DateTimeOffset updatedAt) => new() + { + InstanceId = id, + DefinitionId = "workflow", + Status = "Finished", + SubStatus = "Finished", + CreatedAt = updatedAt.AddMinutes(-1), + UpdatedAt = updatedAt + }; + + private static DirectoryInfo FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "Elsa.sln"))) + return directory; + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); } - 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) + private sealed class TestContributor(string id, int order) : IDashboardContributor { - 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 - }); + public string Id { get; } = id; + + public int Order { get; } = order; + + public DashboardOverviewContribution? Overview { get; init; } + + public IReadOnlyCollection Findings { get; init; } = []; + + public DashboardTrendResponse? Trend { get; init; } + + public DashboardRecentActivityResponse? RecentActivity { get; init; } + + public ValueTask GetOverviewAsync(DashboardContext context) => ValueTask.FromResult(Overview); + + public ValueTask> GetFindingsAsync(DashboardContext context) => ValueTask.FromResult(Findings); + + public ValueTask GetWorkflowTrendsAsync(DashboardTrendContext context) => ValueTask.FromResult(Trend); + + public ValueTask GetRecentActivityAsync(DashboardListContext context) => ValueTask.FromResult(RecentActivity); } - private DefaultDashboardProvider CreateProvider(Action? configureServices = null) + private sealed class ThrowingContributor(string id, int order) : IDashboardContributor { - var services = new ServiceCollection(); - configureServices?.Invoke(services); - return new( - _workflowInstanceStore, - _runtimeAdminService, - new(new TestClock(_now)), - services.BuildServiceProvider(), - new TestHostEnvironment()); + public string Id { get; } = id; + + public int Order { get; } = order; + + public ValueTask GetOverviewAsync(DashboardContext context) => throw new InvalidOperationException("Broken"); + + public ValueTask> GetFindingsAsync(DashboardContext context) => throw new InvalidOperationException("Broken"); + } + + private sealed class CanceledContributor : IDashboardContributor + { + public string Id => "canceled"; + + public int Order => 0; + + public ValueTask GetOverviewAsync(DashboardContext context) => throw new OperationCanceledException(context.CancellationToken); } private sealed class TestClock(DateTimeOffset utcNow) : ISystemClock @@ -231,86 +279,19 @@ public class DefaultDashboardProviderTests 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(); + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = null!; } } + +internal static class DirectoryInfoExtensions +{ + public static string Combine(this DirectoryInfo directory, string path) => Path.Combine(directory.FullName, path); +}