Refactor dashboard API contributors (#7690)

This commit is contained in:
Sipke Schoorstra 2026-06-02 22:25:25 +02:00 committed by GitHub
parent a928c2af2a
commit 577275bfce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1072 additions and 564 deletions

View file

@ -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}

View file

@ -0,0 +1,76 @@
using Elsa.Dashboard.Abstractions.Models;
namespace Elsa.Dashboard.Abstractions.Contracts;
public interface IDashboardContributor
{
string Id { get; }
int Order { get; }
ValueTask<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context)
{
return ValueTask.FromResult<DashboardOverviewContribution?>(null);
}
ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context)
{
return ValueTask.FromResult<IReadOnlyCollection<DashboardFinding>>([]);
}
ValueTask<DashboardTrendResponse?> GetWorkflowTrendsAsync(DashboardTrendContext context)
{
return ValueTask.FromResult<DashboardTrendResponse?>(null);
}
ValueTask<DashboardRecentActivityResponse?> GetRecentActivityAsync(DashboardListContext context)
{
return ValueTask.FromResult<DashboardRecentActivityResponse?>(null);
}
ValueTask<DashboardWorkflowHotspotsResponse?> GetWorkflowHotspotsAsync(DashboardHotspotsContext context)
{
return ValueTask.FromResult<DashboardWorkflowHotspotsResponse?>(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<DashboardMetricCard> Metrics { get; init; } = [];
public IReadOnlyCollection<DashboardPanelSummary> Panels { get; init; } = [];
}

View file

@ -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
{

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Provides shared dashboard contracts and contribution abstractions for Elsa modules.</Description>
<PackageTags>elsa dashboard abstractions contributors operations</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>

View file

@ -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<TContributor>(this IServiceCollection services)
where TContributor : class, IDashboardContributor
{
return services.AddScoped<IDashboardContributor, TContributor>();
}
}

View file

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait />
</Weavers>

View file

@ -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<DashboardMetricCard> Metrics { get; init; } = [];
public IReadOnlyCollection<DashboardPanelSummary> 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);

View file

@ -6,17 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ConsoleLogStreaming.Core" />
<PackageReference Include="CShells" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Diagnostics.ConsoleLogs\Elsa.Diagnostics.ConsoleLogs.csproj" />
<ProjectReference Include="..\Elsa.Diagnostics.StructuredLogs\Elsa.Diagnostics.StructuredLogs.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Runtime\Elsa.Workflows.Runtime.csproj" />
<ProjectReference Include="..\Elsa.Dashboard.Abstractions\Elsa.Dashboard.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1,4 +1,4 @@
using Elsa.Dashboard.Api.Contracts;
using Elsa.Dashboard.Abstractions.Contracts;
using Elsa.Dashboard.Api.Services;
using Microsoft.Extensions.DependencyInjection;

View file

@ -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()

View file

@ -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<DashboardOverviewContribution?> 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<WeatherDashboardContributor>();
```
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<WeatherDashboardWidget>(
"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.

View file

@ -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);

View file

@ -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<IDashboardContributor> contributors,
DashboardRangeResolver rangeResolver,
IServiceProvider serviceProvider,
IHostEnvironment environment) : IDashboardProvider
{
public async Task<DashboardOverview> 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<DashboardOverviewContribution>();
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<DashboardTrendBucket>();
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<DashboardNeedsAttentionResponse> GetNeedsAttentionAsync(DashboardQuery query, int take, CancellationToken cancellationToken = default)
{
var range = rangeResolver.Resolve(query.Range);
var overview = await GetOverviewAsync(query, cancellationToken);
var findings = new List<DashboardFinding>();
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<DashboardRecentActivityResponse> 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<DateTimeOffset?>
{
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<DashboardWorkflowHotspotsResponse> 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<DashboardWorkflowInstanceMetrics> 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<IDashboardContributor> 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<IReadOnlyCollection<T>> CollectAsync<T>(
Func<IDashboardContributor, Task<T?>> action,
CancellationToken cancellationToken)
where T : class
{
var results = new List<T>();
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<IReadOnlyCollection<T>> CollectManyAsync<T>(
Func<IDashboardContributor, Task<IReadOnlyCollection<T>>> 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<T>();
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<DashboardDiagnosticsSummary> GetDiagnosticsSummaryAsync(DashboardRange range, CancellationToken cancellationToken)
private static async Task<T?> ExecuteContributorAsync<T>(
IDashboardContributor contributor,
Func<IDashboardContributor, Task<T>> action,
CancellationToken cancellationToken)
{
var structuredLogs = await GetStructuredLogSummaryAsync(range, cancellationToken);
var consoleLogs = await GetConsoleLogSummaryAsync(range, cancellationToken);
return new()
{
StructuredLogs = structuredLogs,
ConsoleLogs = consoleLogs
};
}
private async Task<DashboardStructuredLogSummary> GetStructuredLogSummaryAsync(DashboardRange range, CancellationToken cancellationToken)
{
var provider = serviceProvider.GetService<IStructuredLogProvider>();
if (provider == null)
return new();
try
{
var sources = await provider.ListSourcesAsync(cancellationToken);
var storageDiagnostics = serviceProvider.GetServices<IStructuredLogStorageDiagnostics>().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<DashboardConsoleLogSummary> GetConsoleLogSummaryAsync(DashboardRange range, CancellationToken cancellationToken)
private static DashboardRuntimeStatus MergeRuntime(IEnumerable<DashboardOverviewContribution> contributions) =>
contributions
.Select(x => x.Runtime)
.FirstOrDefault(x => x != null && x.Status != DashboardRuntimeStatusKeys.Unavailable)
?? new();
private static DashboardWorkflowInstanceMetrics MergeWorkflowMetrics(IEnumerable<DashboardOverviewContribution> contributions)
{
var provider = serviceProvider.GetService<IConsoleLogProvider>();
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<long> 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<TimestampFilter>();
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<DashboardWorkflowInstanceMetrics>().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<DashboardOverviewContribution> 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<string, WorkflowInstanceSummary> 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<DashboardDiagnosticsSummary>().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<TimeSpan?> durations)
{
var values = durations.OfType<TimeSpan>().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;
}

View file

@ -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
{

View file

@ -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<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context)
{
var summary = await GetSummaryAsync(context.Range, context.CancellationToken);
return new()
{
Diagnostics = new()
{
ConsoleLogs = summary
}
};
}
public async ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context)
{
var summary = await GetSummaryAsync(context.Range, context.CancellationToken);
var findings = new List<DashboardFinding>();
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<DashboardConsoleLogSummary> 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
};
}

View file

@ -16,6 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Dashboard.Abstractions\Elsa.Dashboard.Abstractions.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Core\Elsa.Workflows.Core.csproj" />
</ItemGroup>

View file

@ -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<ConsoleLogCaptureShellLease>();
services.TryAddEnumerable(ServiceDescriptor.Scoped<IShellInitializer, ConsoleLogCaptureShellInitializer>());
services.TryAddEnumerable(ServiceDescriptor.Scoped<IDrainHandler, ConsoleLogCaptureShellDrainHandler>());
services.AddDashboardContributor<ConsoleLogsDashboardContributor>();
return services;
}

View file

@ -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<IStructuredLogStorageDiagnostics> storageDiagnostics) : IDashboardContributor
{
public string Id => "diagnostics.structured-logs";
public int Order => 300;
public async ValueTask<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context)
{
var summary = await GetSummaryAsync(context.Range, context.CancellationToken);
return new()
{
Diagnostics = new()
{
StructuredLogs = summary
}
};
}
public async ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context)
{
var summary = await GetSummaryAsync(context.Range, context.CancellationToken);
var findings = new List<DashboardFinding>();
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<DashboardStructuredLogSummary> 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
};
}

View file

@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\..\common\Elsa.Api.Common\Elsa.Api.Common.csproj" />
<ProjectReference Include="..\Elsa.Dashboard.Abstractions\Elsa.Dashboard.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -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<IStructuredLogProvider, DefaultStructuredLogProvider>();
services.TryAddSingleton<StructuredLogSubscriptionManager>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, StructuredLogLoggerProvider>());
services.AddDashboardContributor<StructuredLogsDashboardContributor>();
return services;
}

View file

@ -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<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context)
{
return new()
{
Runtime = GetRuntimeStatus(),
WorkflowInstances = await GetWorkflowMetricsAsync(context.Range, context.IncludeSystem, context.CancellationToken)
};
}
public async ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context)
{
var runtime = GetRuntimeStatus();
var workflowMetrics = await GetWorkflowMetricsAsync(context.Range, context.IncludeSystem, context.CancellationToken);
var findings = new List<DashboardFinding>();
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<DashboardTrendResponse?> GetWorkflowTrendsAsync(DashboardTrendContext context)
{
var bucketSize = GetBucketSize(context.Granularity);
var buckets = new List<DashboardTrendBucket>();
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<DashboardRecentActivityResponse?> GetRecentActivityAsync(DashboardListContext context)
{
var filter = CreateRangeFilter(context.IncludeSystem, nameof(WorkflowInstance.UpdatedAt), context.Range.From, context.Range.To);
var order = new WorkflowInstanceOrder<DateTimeOffset?>
{
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<DashboardWorkflowHotspotsResponse?> 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<DashboardWorkflowInstanceMetrics> 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<long> 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<TimestampFilter>();
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<string, WorkflowInstanceSummary> 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;
}

View file

@ -17,6 +17,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Dashboard.Abstractions\Elsa.Dashboard.Abstractions.csproj" />
<ProjectReference Include="..\Elsa.Caching\Elsa.Caching.csproj" />
<ProjectReference Include="..\Elsa.KeyValues\Elsa.KeyValues.csproj" />
<ProjectReference Include="..\Elsa.Tenants\Elsa.Tenants.csproj" />

View file

@ -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<IWorkflowRuntimeAdminService, Elsa.Workflows.Runtime.Services.WorkflowRuntimeAdminService>()
.AddDashboardContributor<WorkflowDashboardContributor>()
// 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<IInterruptedRecoveryScanner, Elsa.Workflows.Runtime.Services.InterruptedRecoveryScanner>()

View file

@ -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;

View file

@ -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<WorkflowInstance>());
}
[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<IStructuredLogProvider>(new TestStructuredLogProvider(
[new() { Id = "structured-1", DisplayName = "Structured 1", Status = StructuredLogSourceStatus.Stale }],
[new() { Level = StructuredLogLevel.Error, SourceId = "structured-1" }],
droppedEvents: 4));
services.AddSingleton<IStructuredLogStorageDiagnostics>(new TestStructuredLogStorageDiagnostics(3));
services.AddSingleton<IConsoleLogProvider>(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<IStructuredLogProvider>(new TestStructuredLogProvider([], [new() { Level = StructuredLogLevel.Critical, SourceId = "structured-1" }]));
services.AddSingleton<IConsoleLogProvider>(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<OperationCanceledException>(() => 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<IStructuredLogProvider>(new ThrowingStructuredLogProvider(new UnauthorizedAccessException()));
services.AddSingleton<IConsoleLogProvider>(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<DashboardFinding> Findings { get; init; } = [];
public DashboardTrendResponse? Trend { get; init; }
public DashboardRecentActivityResponse? RecentActivity { get; init; }
public ValueTask<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context) => ValueTask.FromResult(Overview);
public ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context) => ValueTask.FromResult(Findings);
public ValueTask<DashboardTrendResponse?> GetWorkflowTrendsAsync(DashboardTrendContext context) => ValueTask.FromResult(Trend);
public ValueTask<DashboardRecentActivityResponse?> GetRecentActivityAsync(DashboardListContext context) => ValueTask.FromResult(RecentActivity);
}
private DefaultDashboardProvider CreateProvider(Action<IServiceCollection>? 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<DashboardOverviewContribution?> GetOverviewAsync(DashboardContext context) => throw new InvalidOperationException("Broken");
public ValueTask<IReadOnlyCollection<DashboardFinding>> GetFindingsAsync(DashboardContext context) => throw new InvalidOperationException("Broken");
}
private sealed class CanceledContributor : IDashboardContributor
{
public string Id => "canceled";
public int Order => 0;
public ValueTask<DashboardOverviewContribution?> 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<QuiescenceState> PauseAsync(string? reason, string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException();
public ValueTask<QuiescenceState> ResumeAsync(string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException();
public ValueTask<DrainOutcome> ForceDrainAsync(string? reason, string? requestedBy, CancellationToken cancellationToken) => throw new NotSupportedException();
}
private sealed class TestStructuredLogProvider(
IReadOnlyCollection<StructuredLogSource> sources,
IReadOnlyCollection<StructuredLogEvent> recentItems,
long droppedEvents = 0) : IStructuredLogProvider
{
public ValueTask PublishAsync(StructuredLogEvent logEvent, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask<RecentStructuredLogsResult> GetRecentAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentStructuredLogsResult(recentItems, droppedEvents));
public async IAsyncEnumerable<StructuredLogEvent> SubscribeAsync(StructuredLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}
public ValueTask<IReadOnlyCollection<StructuredLogSource>> 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<RecentStructuredLogsResult> GetRecentAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => throw exception;
public IAsyncEnumerable<StructuredLogEvent> SubscribeAsync(StructuredLogFilter filter, CancellationToken cancellationToken = default) => throw exception;
public ValueTask<IReadOnlyCollection<StructuredLogSource>> ListSourcesAsync(CancellationToken cancellationToken = default) => throw exception;
}
private sealed class TestStructuredLogStorageDiagnostics(long droppedWriteCount) : IStructuredLogStorageDiagnostics
{
public long DroppedWriteCount { get; } = droppedWriteCount;
}
private sealed class TestConsoleLogProvider(
IReadOnlyCollection<ConsoleLogSource> sources,
IReadOnlyList<ConsoleLogLine> recentItems) : IConsoleLogProvider
{
public ValueTask PublishAsync(ConsoleLogLine line, CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
public ValueTask<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => ValueTask.FromResult(new RecentConsoleLogsResult { Items = recentItems });
public async IAsyncEnumerable<ConsoleLogStreamingItem> SubscribeAsync(ConsoleLogFilter filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> 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<RecentConsoleLogsResult> GetRecentAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => throw exception;
public IAsyncEnumerable<ConsoleLogStreamingItem> SubscribeAsync(ConsoleLogFilter filter, CancellationToken cancellationToken = default) => throw exception;
public ValueTask<IReadOnlyCollection<ConsoleLogSource>> 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);
}