Skip to content

Commit 00a5ea7

Browse files
committed
Add overview dashboard frontend
1 parent c917713 commit 00a5ea7

12 files changed

Lines changed: 1287 additions & 6 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Net.Http.Json;
5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
using System.Threading.Tasks;
8+
using api.Controllers.Models;
9+
using api.Database.Context;
10+
using api.Database.Models;
11+
using Api.Test.Database;
12+
using Xunit;
13+
14+
namespace Api.Test.Controllers;
15+
16+
public class DashboardControllerTests : IAsyncLifetime
17+
{
18+
private TestWebApplicationFactory<Program> _factory = null!;
19+
private SaraDbContext _context = null!;
20+
private DatabaseUtilities _db = null!;
21+
public required HttpClient Client;
22+
23+
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
24+
25+
private static JsonSerializerOptions CreateJsonOptions()
26+
{
27+
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
28+
options.Converters.Add(new JsonStringEnumConverter());
29+
return options;
30+
}
31+
32+
public async ValueTask InitializeAsync()
33+
{
34+
(var _container, string cs) = await TestSetupHelpers.ConfigurePostgreSqlDatabase();
35+
_factory = TestSetupHelpers.ConfigureWebApplicationFactory(cs);
36+
_context = TestSetupHelpers.ConfigurePostgreSqlContext(cs);
37+
_db = new DatabaseUtilities(_context);
38+
Client = TestSetupHelpers.ConfigureHttpClient(_factory);
39+
}
40+
41+
public ValueTask DisposeAsync()
42+
{
43+
GC.SuppressFinalize(this);
44+
return ValueTask.CompletedTask;
45+
}
46+
47+
private async Task<Workflow> SeedWorkflow(
48+
AnalysisRun run,
49+
string workflowType,
50+
WorkflowStatus status,
51+
DateTime? startedAt,
52+
DateTime? completedAt
53+
)
54+
{
55+
var workflow = await _db.NewWorkflow(run, workflowType: workflowType);
56+
workflow.Status = status;
57+
workflow.StartedAt = startedAt;
58+
workflow.CompletedAt = completedAt;
59+
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);
60+
return workflow;
61+
}
62+
63+
private async Task<DashboardSummaryDto> GetSummary(int sinceHours)
64+
{
65+
var response = await Client.GetAsync(
66+
$"/api/dashboard/summary?sinceHours={sinceHours}",
67+
TestContext.Current.CancellationToken
68+
);
69+
Assert.True(response.IsSuccessStatusCode);
70+
var dto = await response.Content.ReadFromJsonAsync<DashboardSummaryDto>(
71+
JsonOptions,
72+
TestContext.Current.CancellationToken
73+
);
74+
Assert.NotNull(dto);
75+
return dto!;
76+
}
77+
78+
[Fact]
79+
public async Task SummaryCountsTerminalWorkflowsWithinWindowAndExcludesOlderOnes()
80+
{
81+
var now = DateTime.UtcNow;
82+
var record = await _db.NewInspectionRecord(blobName: "test");
83+
var analysis = await _db.NewAnalysis(inspectionRecords: [record]);
84+
var run = await _db.NewAnalysisRun(analysis);
85+
86+
// In window (last 24h)
87+
await SeedWorkflow(run, "fencilla", WorkflowStatus.Succeeded, now.AddHours(-2), now.AddHours(-1));
88+
await SeedWorkflow(run, "fencilla", WorkflowStatus.Failed, now.AddHours(-3), now.AddHours(-2));
89+
await SeedWorkflow(run, "cloe", WorkflowStatus.Succeeded, now.AddHours(-5), now.AddHours(-4));
90+
// Out of window
91+
await SeedWorkflow(run, "fencilla", WorkflowStatus.Succeeded, now.AddHours(-40), now.AddHours(-39));
92+
93+
var summary = await GetSummary(24);
94+
95+
Assert.Equal(2, summary.WorkflowStatusCounts.Succeeded);
96+
Assert.Equal(1, summary.WorkflowStatusCounts.Failed);
97+
Assert.Equal(2.0 / 3.0, summary.SuccessRate, 3);
98+
99+
var fencilla = summary.PerWorkflowType.Find(s => s.WorkflowType == "fencilla");
100+
Assert.NotNull(fencilla);
101+
Assert.Equal(2, fencilla!.Total); // one succeeded + one failed in window
102+
Assert.Equal(1, fencilla.Succeeded);
103+
Assert.Equal(1, fencilla.Failed);
104+
Assert.Equal(0.5, fencilla.FailureRate, 3);
105+
}
106+
107+
[Fact]
108+
public async Task SummaryReportsCurrentlyRunningAndStuckWorkflows()
109+
{
110+
var now = DateTime.UtcNow;
111+
var record = await _db.NewInspectionRecord(blobName: "test");
112+
var analysis = await _db.NewAnalysis(inspectionRecords: [record]);
113+
var run = await _db.NewAnalysisRun(analysis);
114+
115+
// Fresh in-progress (not stuck)
116+
await SeedWorkflow(run, "fencilla", WorkflowStatus.InProgress, now.AddMinutes(-5), null);
117+
// Stuck in-progress (older than 30 min threshold)
118+
await SeedWorkflow(run, "cloe", WorkflowStatus.InProgress, now.AddMinutes(-90), null);
119+
120+
var summary = await GetSummary(24);
121+
122+
Assert.Equal(2, summary.CurrentlyRunning.Workflows);
123+
Assert.Single(summary.Stuck);
124+
Assert.Equal("cloe", summary.Stuck[0].WorkflowType);
125+
Assert.True(summary.Stuck[0].MinutesRunning >= 30);
126+
}
127+
128+
[Fact]
129+
public async Task SummaryRejectsWindowOutsideAllowList()
130+
{
131+
var response = await Client.GetAsync(
132+
"/api/dashboard/summary?sinceHours=5",
133+
TestContext.Current.CancellationToken
134+
);
135+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
136+
}
137+
138+
[Fact]
139+
public async Task SummaryTrendHasContiguousBuckets()
140+
{
141+
var summary = await GetSummary(24);
142+
// Hourly buckets across a 24h window => at least 24 buckets.
143+
Assert.True(summary.Trend.Count >= 24);
144+
}
145+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace api.Configurations;
2+
3+
public class DashboardOptions
4+
{
5+
public const string SectionName = "Dashboard";
6+
7+
/// <summary>
8+
/// A workflow that has been InProgress for longer than this is flagged as stuck.
9+
/// </summary>
10+
public int StuckWorkflowThresholdMinutes { get; set; } = 30;
11+
12+
/// <summary>
13+
/// Window sizes (in hours) the summary endpoint accepts. Requests outside
14+
/// this allow-list are rejected to keep trend bucketing bounded.
15+
/// </summary>
16+
public int[] AllowedWindowHours { get; set; } = [24, 168, 720];
17+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using api.Configurations;
2+
using api.Controllers.Models;
3+
using api.Services;
4+
using Microsoft.AspNetCore.Authorization;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace api.Controllers;
9+
10+
[ApiController]
11+
[Route("dashboard")]
12+
public class DashboardController(
13+
ILogger<DashboardController> logger,
14+
IDashboardService service,
15+
IOptions<DashboardOptions> options
16+
) : ControllerBase
17+
{
18+
private readonly DashboardOptions _options = options.Value;
19+
20+
[HttpGet]
21+
[Authorize(Roles = Role.Any)]
22+
[Route("summary")]
23+
[ProducesResponseType(typeof(DashboardSummaryDto), StatusCodes.Status200OK)]
24+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
25+
public async Task<ActionResult<DashboardSummaryDto>> GetSummary([FromQuery] int sinceHours = 24)
26+
{
27+
if (!_options.AllowedWindowHours.Contains(sinceHours))
28+
{
29+
return BadRequest(
30+
$"sinceHours must be one of: {string.Join(", ", _options.AllowedWindowHours)}"
31+
);
32+
}
33+
34+
try
35+
{
36+
return Ok(await service.GetSummary(sinceHours));
37+
}
38+
catch (Exception e)
39+
{
40+
logger.LogError(e, "Error building dashboard summary");
41+
throw;
42+
}
43+
}
44+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
namespace api.Controllers.Models;
2+
3+
/// <summary>
4+
/// Aggregated snapshot of pipeline activity for the SARA overview dashboard.
5+
/// Terminal counts (Succeeded/Failed/Skipped) are bucketed by CompletedAt within
6+
/// the requested window; live counts (InProgress/Pending) reflect the current state.
7+
/// </summary>
8+
public class DashboardSummaryDto
9+
{
10+
public required int WindowHours { get; init; }
11+
public required DateTime Since { get; init; }
12+
public required DateTime GeneratedAt { get; init; }
13+
14+
public required StatusCounts WorkflowStatusCounts { get; init; }
15+
public required StatusCounts RunStatusCounts { get; init; }
16+
17+
/// <summary>Succeeded / (Succeeded + Failed) over the window; 0 when none finished.</summary>
18+
public required double SuccessRate { get; init; }
19+
20+
public required RunningCounts CurrentlyRunning { get; init; }
21+
22+
public required List<WorkflowTypeStat> PerWorkflowType { get; init; }
23+
24+
public required List<StuckWorkflowDto> Stuck { get; init; }
25+
26+
public required AnalysisGroupCounts AnalysisGroupCounts { get; init; }
27+
28+
public required int InspectionRecordsIngested { get; init; }
29+
30+
public required List<TrendBucket> Trend { get; init; }
31+
}
32+
33+
public class StatusCounts
34+
{
35+
public int Pending { get; init; }
36+
public int InProgress { get; init; }
37+
public int Succeeded { get; init; }
38+
public int Failed { get; init; }
39+
public int Skipped { get; init; }
40+
public int Total => Pending + InProgress + Succeeded + Failed + Skipped;
41+
}
42+
43+
public class RunningCounts
44+
{
45+
public int Workflows { get; init; }
46+
public int Runs { get; init; }
47+
}
48+
49+
public class WorkflowTypeStat
50+
{
51+
public required string WorkflowType { get; init; }
52+
public int Total { get; init; }
53+
public int Succeeded { get; init; }
54+
public int Failed { get; init; }
55+
public int Skipped { get; init; }
56+
public double FailureRate { get; init; }
57+
public double? AverageDurationSeconds { get; init; }
58+
}
59+
60+
public class StuckWorkflowDto
61+
{
62+
public required Guid Id { get; init; }
63+
public required string WorkflowType { get; init; }
64+
public Guid AnalysisRunId { get; init; }
65+
public DateTime? StartedAt { get; init; }
66+
public double MinutesRunning { get; init; }
67+
}
68+
69+
public class AnalysisGroupCounts
70+
{
71+
public int Pending { get; init; }
72+
public int Complete { get; init; }
73+
public int TimedOut { get; init; }
74+
}
75+
76+
public class TrendBucket
77+
{
78+
public required DateTime BucketStart { get; init; }
79+
public int Succeeded { get; init; }
80+
public int Failed { get; init; }
81+
}

api/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
builder.Services.Configure<AzureAdOptions>(builder.Configuration.GetSection("AzureAd"));
6363
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
6464
builder.Services.Configure<EndpointConfig>(builder.Configuration.GetSection("EndpointConfig"));
65+
builder.Services.Configure<DashboardOptions>(
66+
builder.Configuration.GetSection(DashboardOptions.SectionName)
67+
);
6568
builder
6669
.Services.AddOptions<AnalysisOptions>()
6770
.Bind(builder.Configuration.GetSection(AnalysisOptions.SectionName))
@@ -79,6 +82,7 @@
7982
builder.Services.AddScoped<IAnalysisGroupService, AnalysisGroupService>();
8083
builder.Services.AddScoped<IAnalysisRunService, AnalysisRunService>();
8184
builder.Services.AddScoped<IMqttPublisherService, MqttPublisherService>();
85+
builder.Services.AddScoped<IDashboardService, DashboardService>();
8286

8387
builder.Services.AddScoped<IWorkflowService, WorkflowService>();
8488
builder.Services.AddHttpClient(WorkflowService.ArgoHttpClientName);

0 commit comments

Comments
 (0)