Refactor dashboard API contributors (#7690)
This commit is contained in:
parent
a928c2af2a
commit
577275bfce
15
Elsa.sln
15
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}
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
3
src/modules/Elsa.Dashboard.Abstractions/FodyWeavers.xml
Normal file
3
src/modules/Elsa.Dashboard.Abstractions/FodyWeavers.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Elsa.Dashboard.Api.Contracts;
|
||||
using Elsa.Dashboard.Abstractions.Contracts;
|
||||
using Elsa.Dashboard.Api.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue