Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Text.Json;
using HealthMonitoring.Api;

namespace HealthMonitoring.Tests.Api;

public sealed class ServerStatusValidatorTests
{
[Fact]
public void Valid_request_calculates_overall_status_from_checks()
{
var request = new ServerStatusRequest
{
ServerName = "TRANSACTION-SERVER-01",
Checks =
[
Check("PowerShell Runtime", "Passed"),
Check("Disk Space", "Warning"),
Check("HealthMonitoring", "Passed")
]
};

request.OverallStatus = "Passed";
var result = ServerStatusValidator.Create("transaction-processor", request, DateTimeOffset.UtcNow);

Assert.Empty(result.Errors);
Assert.NotNull(result.Status);
Assert.Equal("Warning", result.Status!.OverallStatus);
Assert.Equal("transaction-processor", result.Status.ServerId);
Assert.Equal("TRANSACTION-SERVER-01", result.Status.ServerName);
}

[Fact]
public void Request_requires_server_name_and_at_least_one_check()
{
var result = ServerStatusValidator.Create(
"transaction-processor",
new ServerStatusRequest { ServerName = " ", Checks = [] },
DateTimeOffset.UtcNow);

Assert.Contains(result.Errors, error => error.Contains("ServerName", StringComparison.Ordinal));
Assert.Contains(result.Errors, error => error.Contains("Checks", StringComparison.Ordinal));
}

[Fact]
public void Request_rejects_unknown_check_status()
{
var result = ServerStatusValidator.Create(
"transaction-processor",
new ServerStatusRequest
{
ServerName = "SERVER-01",
Checks = [Check("Disk Space", "Unknown")]
},
DateTimeOffset.UtcNow);

Assert.Contains(result.Errors, error => error.Contains("Status", StringComparison.Ordinal));
Assert.Null(result.Status);
}

private static SupportCheckResultRequest Check(string name, string status) => new()
{
Name = name,
Status = status,
Summary = $"{name} is {status}.",
Details = JsonDocument.Parse("{}").RootElement.Clone()
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ private sealed class InMemoryRepository : IHealthMonitoringRepository
public Task<MonitoredService?> GetServiceAsync(string serviceId, CancellationToken cancellationToken) => Task.FromResult(SavedService);
public Task<MonitoredService> UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) { SavedService = service; return Task.FromResult(service); }
public Task<IReadOnlyList<MonitoredService>> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<ServerStatus?> GetServerStatusAsync(string serverId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<ServerStatus> UpsertServerStatusAsync(ServerStatus status, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task ArchiveServiceAsync(string serviceId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task AddObservationAsync(HealthObservation observation, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<HealthObservation>> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken) => throw new NotSupportedException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ public async Task Observation_and_checks_are_persisted_and_read_newest_first()
Assert.Single(observations[0].Checks);
}

[Fact]
public async Task Server_status_upsert_keeps_one_latest_row_per_server()
{
await using var database = await SqlServerTestDatabase.TryCreateAsync();
if (database is null) return;

var first = NewServerStatus("server-1", "SERVER-01", "Passed", "[]");
var second = NewServerStatus("server-1", "SERVER-01", "Warning", "[{\"name\":\"Disk Space\"}]");

await database.Repository.UpsertServerStatusAsync(first, CancellationToken.None);
await database.Repository.UpsertServerStatusAsync(second, CancellationToken.None);

var current = await database.Repository.GetServerStatusAsync("server-1", CancellationToken.None);

Assert.NotNull(current);
Assert.Equal(first.Id, current.Id);
Assert.Equal("Warning", current.OverallStatus);
Assert.Equal(second.ChecksJson, current.ChecksJson);
}

private static MonitoredService NewService(string serviceId) => new()
{
Id = Guid.NewGuid(),
Expand All @@ -64,6 +84,17 @@ public async Task Observation_and_checks_are_persisted_and_read_newest_first()
HttpStatusCode = 200,
RawResponseJson = raw
};

private static ServerStatus NewServerStatus(string serverId, string serverName, string overallStatus, string checksJson) => new()
{
Id = Guid.NewGuid(),
ServerId = serverId,
ServerName = serverName,
OverallStatus = overallStatus,
ChecksJson = checksJson,
ReceivedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}

public sealed class SqlServerTestDatabase : IAsyncDisposable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.Json;
using HealthMonitoring.Domain;
using HealthMonitoring.Reporting;

namespace HealthMonitoring.Tests.Reporting;

public sealed class ServerStatusDashboardMapperTests
{
[Fact]
public void Maps_latest_server_status_and_preserves_structured_sub_checks()
{
var status = new ServerStatus
{
ServerId = "transaction-processor",
ServerName = "TRANSACTION-SERVER-01",
OverallStatus = "Warning",
UpdatedAtUtc = DateTimeOffset.Parse("2026-10-14T06:00:00Z"),
ChecksJson = "[{\"name\":\"Disk Space\",\"status\":\"Warning\",\"summary\":\"Drive C is low.\",\"durationMs\":12.5,\"details\":[{\"drive\":\"C:\",\"freePercent\":12,\"thresholdPercent\":15}]}]"
};

var result = ServerStatusDashboardMapper.Map(status);

Assert.Equal("TRANSACTION-SERVER-01", result.ServerName);
Assert.Equal("Warning", result.OverallStatus);
var check = Assert.Single(result.Checks);
Assert.Equal("Disk Space", check.Name);
Assert.Equal(JsonValueKind.Array, check.Details!.Value.ValueKind);
Assert.Equal("C:", check.Details.Value[0].GetProperty("drive").GetString());
}

[Fact]
public void Maps_disk_sub_checks_to_progress_bar_values_without_byte_fields()
{
using var document = JsonDocument.Parse("""[{"drive":"C:","sizeBytes":999,"freeBytes":500,"freePercent":50.5,"status":"Passed"}]""");

var result = ServerCheckDetailsMapper.MapDisk(document.RootElement);

var disk = Assert.Single(result);
Assert.Equal("C:", disk.Drive);
Assert.Equal(50.5, disk.FreePercent);
Assert.Equal("Passed", disk.Status);
}
}
87 changes: 87 additions & 0 deletions HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text.Json;
using HealthMonitoring.Domain;

namespace HealthMonitoring.Api;

public sealed class ServerStatusRequest
{
public string? ServerName { get; set; }
public string? OverallStatus { get; set; }
public List<SupportCheckResultRequest> Checks { get; set; } = [];
}

public sealed class SupportCheckResultRequest
{
public string? Name { get; set; }
public string? Status { get; set; }
public string? Summary { get; set; }
public JsonElement? Details { get; set; }
public string? StartedAt { get; set; }
public string? CompletedAt { get; set; }
public double DurationMs { get; set; }
public string? Error { get; set; }
}

public sealed record ServerStatusResponse(
string ServerId,
string ServerName,
string OverallStatus,
DateTimeOffset ReceivedAtUtc,
DateTimeOffset UpdatedAtUtc,
IReadOnlyList<SupportCheckResultRequest> Checks);

public sealed record ServerStatusValidationResult(IReadOnlyList<string> Errors, ServerStatus? Status);

public static class ServerStatusValidator
{
private static readonly HashSet<string> AllowedStatuses = ["Passed", "Warning", "Failed"];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

public static ServerStatusValidationResult Create(string serverId, ServerStatusRequest request, DateTimeOffset now)
{
var errors = ValidateRequest(serverId, request);

if (errors.Count > 0) return new ServerStatusValidationResult(errors, null);

var checks = request.Checks!;
return new ServerStatusValidationResult(errors, new ServerStatus
{
Id = Guid.NewGuid(),
ServerId = serverId,
ServerName = request.ServerName!.Trim(),
OverallStatus = CalculateOverallStatus(checks),
ChecksJson = JsonSerializer.Serialize(checks, JsonOptions),
ReceivedAtUtc = now,
UpdatedAtUtc = now
});
}

private static List<string> ValidateRequest(string serverId, ServerStatusRequest request)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(serverId)) errors.Add("ServerId is required.");
if (string.IsNullOrWhiteSpace(request.ServerName)) errors.Add("ServerName is required.");
if (request.Checks is null || request.Checks.Count == 0) errors.Add("Checks must contain at least one check.");

foreach (var check in request.Checks ?? [])
AddCheckValidationErrors(errors, check);

return errors;
}

private static void AddCheckValidationErrors(List<string> errors, SupportCheckResultRequest check)
{
if (string.IsNullOrWhiteSpace(check.Name)) errors.Add("Checks.Name is required.");
if (string.IsNullOrWhiteSpace(check.Status) || !AllowedStatuses.Contains(check.Status))
errors.Add($"Checks.Status must be one of: {string.Join(", ", AllowedStatuses)}.");
if (string.IsNullOrWhiteSpace(check.Summary)) errors.Add("Checks.Summary is required.");
if (check.DurationMs < 0) errors.Add("Checks.DurationMs cannot be negative.");
}

private static string CalculateOverallStatus(IReadOnlyList<SupportCheckResultRequest> checks) =>
checks.Any(check => check.Status == "Failed")
? "Failed"
: checks.Any(check => check.Status == "Warning")
? "Warning"
: "Passed";
}
41 changes: 41 additions & 0 deletions HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Text.Json;
using HealthMonitoring.Persistence;

namespace HealthMonitoring.Api;

public static class ServerStatusEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

public static IEndpointRouteBuilder MapServerStatusEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/services/{serverId}/support-checks", async (
string serverId,
ServerStatusRequest request,
IHealthMonitoringRepository repository,
CancellationToken cancellationToken) =>
{
var service = await repository.GetServiceAsync(serverId, cancellationToken);
if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound();

var validation = ServerStatusValidator.Create(serverId, request, DateTimeOffset.UtcNow);
if (validation.Errors.Count > 0)
return Results.ValidationProblem(validation.Errors
.GroupBy(error => error.Split('.')[0], StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.OrdinalIgnoreCase));

var existing = await repository.GetServerStatusAsync(serverId, cancellationToken);
var status = validation.Status!;
status.Id = existing?.Id ?? status.Id;
var saved = await repository.UpsertServerStatusAsync(status, cancellationToken);
var checks = JsonSerializer.Deserialize<List<SupportCheckResultRequest>>(saved.ChecksJson, JsonOptions) ?? [];
var response = new ServerStatusResponse(saved.ServerId, saved.ServerName, saved.OverallStatus, saved.ReceivedAtUtc, saved.UpdatedAtUtc, checks);

return existing is null
? Results.Created($"/api/services/{saved.ServerId}/support-checks", response)
: Results.Ok(response);
});

return endpoints;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<div class="nav-section-label">Workspace</div>
<nav>
<NavLink href="/">Health Monitoring</NavLink>
<NavLink href="/server-checks">Server Checks</NavLink>
<span class="nav-future" aria-disabled="true">Incidents <span>Soon</span></span>
<span class="nav-future" aria-disabled="true">Service Catalogue <span>Soon</span></span>
<span class="nav-future" aria-disabled="true">Runbooks <span>Soon</span></span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
@page "/server-checks"
@inject IDashboardQueryService Dashboard
@inject ILogger<ServerChecks> Logger

<PageTitle>Server Checks · Operational Support</PageTitle>
<div class="page-heading"><div><p class="eyebrow">OPERATIONAL SUPPORT · SERVER CHECKS</p><h1>Server checks</h1><p class="muted">The latest daily support-check payload from the monitored server.</p></div><a class="button secondary" href="/">Back to overview</a></div>

@if (_isLoading)
{
<p class="panel">Loading server checks…</p>
}
else if (_model is null)
{
<p class="panel empty">No server check payload has been received yet.</p>
}
else
{
<section class="server-check-summary">
<div><p class="eyebrow">SERVER</p><h2>@_model.ServerName</h2><p class="muted">@_model.ServerId</p></div>
<div class="server-check-summary-status"><ServerCheckStatusBadge Status="@_model.OverallStatus" /><span>Updated @_model.UpdatedAtUtc.ToLocalTime().ToString("dd MMM yyyy, HH:mm:ss")</span></div>
</section>
<section class="panel server-check-panel">
<div class="server-check-panel-heading"><h2>Check results</h2><span class="muted">@VisibleChecks.Count check(s)</span></div>
<div class="server-check-table server-check-table-header"><span>Check</span><span>Status</span><span>Summary</span><span>Duration</span><span aria-hidden="true"></span></div>
@foreach (var check in VisibleChecks)
{
<details class="server-check-collapsible">
<summary class="server-check-table server-check-row">
<strong>@check.Name</strong>
<ServerCheckStatusBadge Status="@check.Status" />
<span class="server-check-summary">@check.Summary</span>
<span class="server-check-duration">@check.DurationMs.ToString("0") ms</span>
<span class="server-check-chevron" aria-hidden="true">⌄</span>
</summary>
<div class="server-check-expanded">
@if (check.Details is { } details && details.ValueKind != System.Text.Json.JsonValueKind.Null && details.ValueKind != System.Text.Json.JsonValueKind.Undefined)
{
<div class="server-check-details"><ServerCheckDetails Details="@check.Details" Heading="@GetDetailsHeading(check.Name)" /></div>
}
@if (!string.IsNullOrWhiteSpace(check.Error) && check.Name != "KurrentDB Write Activity")
{
<div class="server-check-error @(check.Name == "KurrentDB Write Activity" ? "server-check-error-neutral" : string.Empty)">@check.Error</div>
}
</div>
</details>
}
</section>
}

@code {
private ServerStatusDashboardModel? _model;
private bool _isLoading = true;
private IReadOnlyList<ServerCheckDashboardModel> VisibleChecks =>
_model?.Checks.Where(check => !string.Equals(check.Name, "HealthMonitoring", StringComparison.OrdinalIgnoreCase)).ToArray()
?? [];

protected override async Task OnInitializedAsync()
{
try
{
_model = await Dashboard.GetLatestServerStatusAsync(CancellationToken.None);
}
catch (Exception exception)
{
Logger.LogError(exception, "Unable to load latest server checks.");
}
finally
{
_isLoading = false;
}
}

private static string GetDetailsHeading(string checkName) => checkName switch
{
"Disk Space" => "Drive-level results",
"HealthMonitoring" => "Service-level results",
"Subscription Service" => "Subscription-level results",
"KurrentDB Projections" => "Projection-level results",
"Scheduled Tasks" => "Task-level results",
"KurrentDB Write Activity" => "Stream-level results",
_ => "Sub-check results"
};
}
Loading
Loading