diff --git a/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs new file mode 100644 index 0000000..b8b1f46 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs @@ -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() + }; +} diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs index cd00354..aff78a7 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -135,6 +135,8 @@ private sealed class InMemoryRepository : IHealthMonitoringRepository public Task GetServiceAsync(string serviceId, CancellationToken cancellationToken) => Task.FromResult(SavedService); public Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) { SavedService = service; return Task.FromResult(service); } public Task> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetServerStatusAsync(string serverId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task 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> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); diff --git a/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs index c0f0b85..9a57662 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs @@ -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(), @@ -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 diff --git a/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs new file mode 100644 index 0000000..8f152ff --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs @@ -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); + } +} diff --git a/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs b/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs new file mode 100644 index 0000000..555bbca --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs @@ -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 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 Checks); + +public sealed record ServerStatusValidationResult(IReadOnlyList Errors, ServerStatus? Status); + +public static class ServerStatusValidator +{ + private static readonly HashSet 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 ValidateRequest(string serverId, ServerStatusRequest request) + { + var errors = new List(); + 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 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 checks) => + checks.Any(check => check.Status == "Failed") + ? "Failed" + : checks.Any(check => check.Status == "Warning") + ? "Warning" + : "Passed"; +} diff --git a/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs b/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs new file mode 100644 index 0000000..fb99876 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs @@ -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>(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; + } +} diff --git a/HealthMonitoring/HealthMonitoring/Components/Layout/MainLayout.razor b/HealthMonitoring/HealthMonitoring/Components/Layout/MainLayout.razor index b7c20bd..a74c7d6 100644 --- a/HealthMonitoring/HealthMonitoring/Components/Layout/MainLayout.razor +++ b/HealthMonitoring/HealthMonitoring/Components/Layout/MainLayout.razor @@ -6,6 +6,7 @@