From 8d1e9d19f877b1b5c94cb70bfa86bfb9721f8e88 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 7 Nov 2026 14:17:16 +0000 Subject: [PATCH 1/2] fix: key support checks by server name --- .../Api/ServerStatusValidatorTests.cs | 9 +- .../Monitoring/InitializationStateTests.cs | 2 +- .../Persistence/RepositoryTests.cs | 9 +- .../ServerStatusDashboardMapperTests.cs | 1 - .../Api/ServerStatusContracts.cs | 16 +- .../Api/ServerStatusEndpoints.cs | 15 +- .../Components/Pages/ServerChecks.razor | 2 +- .../HealthMonitoring/Domain/ServerStatus.cs | 1 - ...0_UseServerNameForServerStatus.Designer.cs | 353 ++++++++++++++++++ ...1107140250_UseServerNameForServerStatus.cs | 73 ++++ .../HealthMonitoringDbContextModelSnapshot.cs | 9 +- .../Persistence/HealthMonitoringDbContext.cs | 3 +- .../Persistence/HealthMonitoringRepository.cs | 8 +- .../IHealthMonitoringRepository.cs | 2 +- .../Reporting/ServerStatusDashboardModels.cs | 3 +- .../Invoke-DailySupportCheck.ps1 | 13 +- ScheduledTasks/DailySupportCheck/README.md | 5 +- .../Tests/Invoke-DailySupportCheck.Tests.ps1 | 21 +- .../daily-support-check.json | 3 +- .../daily-support-check.local.example.json | 3 +- .../2026-11-07-server-name-support-checks.md | 81 ++++ ...11-07-server-name-support-checks-design.md | 57 +++ 22 files changed, 614 insertions(+), 75 deletions(-) create mode 100644 HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.Designer.cs create mode 100644 HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.cs create mode 100644 docs/superpowers/plans/2026-11-07-server-name-support-checks.md create mode 100644 docs/superpowers/specs/2026-11-07-server-name-support-checks-design.md diff --git a/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs index b8b1f46..b6bf4ac 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Api/ServerStatusValidatorTests.cs @@ -10,7 +10,6 @@ public void Valid_request_calculates_overall_status_from_checks() { var request = new ServerStatusRequest { - ServerName = "TRANSACTION-SERVER-01", Checks = [ Check("PowerShell Runtime", "Passed"), @@ -20,12 +19,11 @@ public void Valid_request_calculates_overall_status_from_checks() }; request.OverallStatus = "Passed"; - var result = ServerStatusValidator.Create("transaction-processor", request, DateTimeOffset.UtcNow); + var result = ServerStatusValidator.Create(" TRANSACTION-SERVER-01 ", 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); } @@ -33,8 +31,8 @@ public void Valid_request_calculates_overall_status_from_checks() public void Request_requires_server_name_and_at_least_one_check() { var result = ServerStatusValidator.Create( - "transaction-processor", - new ServerStatusRequest { ServerName = " ", Checks = [] }, + " ", + new ServerStatusRequest { Checks = [] }, DateTimeOffset.UtcNow); Assert.Contains(result.Errors, error => error.Contains("ServerName", StringComparison.Ordinal)); @@ -48,7 +46,6 @@ public void Request_rejects_unknown_check_status() "transaction-processor", new ServerStatusRequest { - ServerName = "SERVER-01", Checks = [Check("Disk Space", "Unknown")] }, DateTimeOffset.UtcNow); diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs index aff78a7..7c9b759 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -135,7 +135,7 @@ 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 GetServerStatusAsync(string serverName, 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(); diff --git a/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs index 9a57662..ae83bcb 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Persistence/RepositoryTests.cs @@ -51,13 +51,13 @@ 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\"}]"); + var first = NewServerStatus(" SERVER-01 ", "Passed", "[]"); + var second = NewServerStatus("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); + var current = await database.Repository.GetServerStatusAsync("SERVER-01", CancellationToken.None); Assert.NotNull(current); Assert.Equal(first.Id, current.Id); @@ -85,10 +85,9 @@ public async Task Server_status_upsert_keeps_one_latest_row_per_server() RawResponseJson = raw }; - private static ServerStatus NewServerStatus(string serverId, string serverName, string overallStatus, string checksJson) => new() + private static ServerStatus NewServerStatus(string serverName, string overallStatus, string checksJson) => new() { Id = Guid.NewGuid(), - ServerId = serverId, ServerName = serverName, OverallStatus = overallStatus, ChecksJson = checksJson, diff --git a/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs index 8f152ff..a0fbcbc 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Reporting/ServerStatusDashboardMapperTests.cs @@ -11,7 +11,6 @@ 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"), diff --git a/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs b/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs index 555bbca..b69bd51 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs @@ -5,7 +5,6 @@ namespace HealthMonitoring.Api; public sealed class ServerStatusRequest { - public string? ServerName { get; set; } public string? OverallStatus { get; set; } public List Checks { get; set; } = []; } @@ -23,7 +22,6 @@ public sealed class SupportCheckResultRequest } public sealed record ServerStatusResponse( - string ServerId, string ServerName, string OverallStatus, DateTimeOffset ReceivedAtUtc, @@ -37,9 +35,10 @@ 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) + public static ServerStatusValidationResult Create(string serverName, ServerStatusRequest request, DateTimeOffset now) { - var errors = ValidateRequest(serverId, request); + serverName = serverName?.Trim() ?? string.Empty; + var errors = ValidateRequest(serverName, request); if (errors.Count > 0) return new ServerStatusValidationResult(errors, null); @@ -47,8 +46,7 @@ public static ServerStatusValidationResult Create(string serverId, ServerStatusR return new ServerStatusValidationResult(errors, new ServerStatus { Id = Guid.NewGuid(), - ServerId = serverId, - ServerName = request.ServerName!.Trim(), + ServerName = serverName, OverallStatus = CalculateOverallStatus(checks), ChecksJson = JsonSerializer.Serialize(checks, JsonOptions), ReceivedAtUtc = now, @@ -56,11 +54,11 @@ public static ServerStatusValidationResult Create(string serverId, ServerStatusR }); } - private static List ValidateRequest(string serverId, ServerStatusRequest request) + private static List ValidateRequest(string serverName, 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 (string.IsNullOrWhiteSpace(serverName)) errors.Add("ServerName is required."); + if (serverName.Length > 300) errors.Add("ServerName cannot exceed 300 characters."); if (request.Checks is null || request.Checks.Count == 0) errors.Add("Checks must contain at least one check."); foreach (var check in request.Checks ?? []) diff --git a/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs b/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs index fb99876..1be9cc4 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs @@ -9,30 +9,27 @@ public static class ServerStatusEndpoints public static IEndpointRouteBuilder MapServerStatusEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapPost("/api/services/{serverId}/support-checks", async ( - string serverId, + endpoints.MapPost("/api/servers/{serverName}/support-checks", async ( + string serverName, 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); + var validation = ServerStatusValidator.Create(serverName, 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 existing = await repository.GetServerStatusAsync(validation.Status!.ServerName, 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); + var response = new ServerStatusResponse(saved.ServerName, saved.OverallStatus, saved.ReceivedAtUtc, saved.UpdatedAtUtc, checks); return existing is null - ? Results.Created($"/api/services/{saved.ServerId}/support-checks", response) + ? Results.Created($"/api/servers/{Uri.EscapeDataString(saved.ServerName)}/support-checks", response) : Results.Ok(response); }); diff --git a/HealthMonitoring/HealthMonitoring/Components/Pages/ServerChecks.razor b/HealthMonitoring/HealthMonitoring/Components/Pages/ServerChecks.razor index 5a5d7d8..75bb3a7 100644 --- a/HealthMonitoring/HealthMonitoring/Components/Pages/ServerChecks.razor +++ b/HealthMonitoring/HealthMonitoring/Components/Pages/ServerChecks.razor @@ -16,7 +16,7 @@ else if (_model is null) else {
-

SERVER

@_model.ServerName

@_model.ServerId

+

SERVER

@_model.ServerName

Updated @_model.UpdatedAtUtc.ToLocalTime().ToString("dd MMM yyyy, HH:mm:ss")
diff --git a/HealthMonitoring/HealthMonitoring/Domain/ServerStatus.cs b/HealthMonitoring/HealthMonitoring/Domain/ServerStatus.cs index b7dd98f..bb79c95 100644 --- a/HealthMonitoring/HealthMonitoring/Domain/ServerStatus.cs +++ b/HealthMonitoring/HealthMonitoring/Domain/ServerStatus.cs @@ -3,7 +3,6 @@ namespace HealthMonitoring.Domain; public sealed class ServerStatus { public Guid Id { get; set; } - public string ServerId { get; set; } = string.Empty; public string ServerName { get; set; } = string.Empty; public string OverallStatus { get; set; } = string.Empty; public string ChecksJson { get; set; } = "[]"; diff --git a/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.Designer.cs b/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.Designer.cs new file mode 100644 index 0000000..7551773 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.Designer.cs @@ -0,0 +1,353 @@ +// +using System; +using HealthMonitoring.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace HealthMonitoring.Migrations +{ + [DbContext(typeof(HealthMonitoringDbContext))] + [Migration("20261107140250_UseServerNameForServerStatus")] + partial class UseServerNameForServerStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("HealthMonitoring.Domain.HealthCheckResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("Exception") + .HasColumnType("nvarchar(max)"); + + b.Property("HealthObservationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HealthObservationId"); + + b.ToTable("HealthCheckResults"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.HealthObservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("MonitoredServiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("ObservedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RawResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseDuration") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("MonitoredServiceId", "ObservedAtUtc"); + + b.ToTable("HealthObservations"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.MonitoredService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ArchivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ConnectionString") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Environment") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Group") + .HasColumnType("nvarchar(max)"); + + b.Property("HealthUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("Host") + .HasColumnType("nvarchar(max)"); + + b.Property("IgnoreCertificateErrors") + .HasColumnType("bit"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("LastRegisteredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("MonitorType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("PollingInterval") + .HasColumnType("bigint"); + + b.Property("RequestTimeout") + .HasColumnType("bigint"); + + b.Property("RetentionPeriod") + .HasColumnType("bigint"); + + b.Property("ServiceId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("StatusPolicyJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Version") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId") + .IsUnique(); + + b.ToTable("MonitoredServices"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.ServerStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChecksJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OverallStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ServerName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ServerName") + .IsUnique(); + + b.ToTable("ServerStatus", (string)null); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.ServiceDependencyLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DependencyName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("MonitoredServiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("TargetMonitoredServiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("TargetMonitoredServiceId"); + + b.HasIndex("MonitoredServiceId", "DependencyName") + .IsUnique(); + + b.ToTable("ServiceDependencyLinks"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.ServiceIncident", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("FirstObservationId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastObservationId") + .HasColumnType("uniqueidentifier"); + + b.Property("MonitoredServiceId") + .HasColumnType("uniqueidentifier"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StartedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("MonitoredServiceId", "StartedAtUtc"); + + b.ToTable("ServiceIncidents"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.ServiceStatusSnapshot", b => + { + b.Property("MonitoredServiceId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentIncidentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("LastObservationId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastObservedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastResponseDuration") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("MonitoredServiceId"); + + b.ToTable("ServiceStatusSnapshots"); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.HealthCheckResult", b => + { + b.HasOne("HealthMonitoring.Domain.HealthObservation", null) + .WithMany("Checks") + .HasForeignKey("HealthObservationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.ServiceDependencyLink", b => + { + b.HasOne("HealthMonitoring.Domain.MonitoredService", null) + .WithMany() + .HasForeignKey("MonitoredServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("HealthMonitoring.Domain.MonitoredService", null) + .WithMany() + .HasForeignKey("TargetMonitoredServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("HealthMonitoring.Domain.HealthObservation", b => + { + b.Navigation("Checks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.cs b/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.cs new file mode 100644 index 0000000..4a9b814 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Migrations/20261107140250_UseServerNameForServerStatus.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HealthMonitoring.Migrations +{ + /// + public partial class UseServerNameForServerStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF EXISTS ( + SELECT 1 + FROM [ServerStatus] + WHERE NULLIF(LTRIM(RTRIM([ServerName])), N'') IS NULL) + THROW 51000, 'ServerStatus migration cannot continue: a report has an empty server name. Set a unique server name for each report and retry.', 1; + + IF EXISTS ( + SELECT 1 + FROM [ServerStatus] + GROUP BY LTRIM(RTRIM([ServerName])) + HAVING COUNT_BIG(*) > 1) + THROW 51000, 'ServerStatus migration cannot continue: server names are duplicated after trimming. Reconcile the rows to one unique server name per server and retry.', 1; + + UPDATE [ServerStatus] + SET [ServerName] = LTRIM(RTRIM([ServerName])); + """); + + migrationBuilder.DropIndex( + name: "IX_ServerStatus_ServerId", + table: "ServerStatus"); + + migrationBuilder.DropColumn( + name: "ServerId", + table: "ServerStatus"); + + migrationBuilder.CreateIndex( + name: "IX_ServerStatus_ServerName", + table: "ServerStatus", + column: "ServerName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + IF EXISTS (SELECT 1 FROM [ServerStatus]) + THROW 51000, 'ServerStatus migration cannot be rolled back automatically because the original ServerId values were removed. Restore a database backup from before the migration or manually map each server name to its original ServerId before retrying.', 1; + """); + + migrationBuilder.DropIndex( + name: "IX_ServerStatus_ServerName", + table: "ServerStatus"); + + migrationBuilder.AddColumn( + name: "ServerId", + table: "ServerStatus", + type: "nvarchar(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_ServerStatus_ServerId", + table: "ServerStatus", + column: "ServerId", + unique: true); + } + } +} diff --git a/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs b/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs index 97619d8..ca569c3 100644 --- a/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs +++ b/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.14") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -203,11 +203,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReceivedAtUtc") .HasColumnType("datetimeoffset"); - b.Property("ServerId") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - b.Property("ServerName") .IsRequired() .HasMaxLength(300) @@ -218,7 +213,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ServerId") + b.HasIndex("ServerName") .IsUnique(); b.ToTable("ServerStatus", (string)null); diff --git a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs index 55e2e79..095eb95 100644 --- a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs +++ b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs @@ -105,8 +105,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("ServerStatus"); entity.HasKey(status => status.Id); - entity.HasIndex(status => status.ServerId).IsUnique(); - entity.Property(status => status.ServerId).HasMaxLength(200).IsRequired(); + entity.HasIndex(status => status.ServerName).IsUnique(); entity.Property(status => status.ServerName).HasMaxLength(300).IsRequired(); entity.Property(status => status.OverallStatus).HasMaxLength(20).IsRequired(); entity.Property(status => status.ChecksJson).HasColumnType(NvarcharMaxColumnType).IsRequired(); diff --git a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringRepository.cs b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringRepository.cs index 26ba540..8ad8e92 100644 --- a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringRepository.cs +++ b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringRepository.cs @@ -26,19 +26,19 @@ public async Task> ListServicesAsync(bool includ return services.Select(HydratePolicy).ToArray()!; } - public Task GetServerStatusAsync(string serverId, CancellationToken cancellationToken) => - dbContext.ServerStatuses.AsNoTracking().SingleOrDefaultAsync(status => status.ServerId == serverId, cancellationToken); + public Task GetServerStatusAsync(string serverName, CancellationToken cancellationToken) => + dbContext.ServerStatuses.AsNoTracking().SingleOrDefaultAsync(status => status.ServerName == serverName.Trim(), cancellationToken); public async Task UpsertServerStatusAsync(ServerStatus status, CancellationToken cancellationToken) { - var existing = await dbContext.ServerStatuses.SingleOrDefaultAsync(item => item.ServerId == status.ServerId, cancellationToken); + status.ServerName = status.ServerName.Trim(); + var existing = await dbContext.ServerStatuses.SingleOrDefaultAsync(item => item.ServerName == status.ServerName, cancellationToken); if (existing is null) { dbContext.ServerStatuses.Add(status); } else { - existing.ServerName = status.ServerName; existing.OverallStatus = status.OverallStatus; existing.ChecksJson = status.ChecksJson; existing.ReceivedAtUtc = status.ReceivedAtUtc; diff --git a/HealthMonitoring/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs b/HealthMonitoring/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs index 5cda439..67105fb 100644 --- a/HealthMonitoring/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs +++ b/HealthMonitoring/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs @@ -6,7 +6,7 @@ public interface IHealthMonitoringRepository { Task GetServiceAsync(string serviceId, CancellationToken cancellationToken); Task> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken); - Task GetServerStatusAsync(string serverId, CancellationToken cancellationToken); + Task GetServerStatusAsync(string serverName, CancellationToken cancellationToken); Task UpsertServerStatusAsync(ServerStatus status, CancellationToken cancellationToken); Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken); Task ArchiveServiceAsync(string serviceId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken); diff --git a/HealthMonitoring/HealthMonitoring/Reporting/ServerStatusDashboardModels.cs b/HealthMonitoring/HealthMonitoring/Reporting/ServerStatusDashboardModels.cs index adbf696..b072700 100644 --- a/HealthMonitoring/HealthMonitoring/Reporting/ServerStatusDashboardModels.cs +++ b/HealthMonitoring/HealthMonitoring/Reporting/ServerStatusDashboardModels.cs @@ -4,7 +4,6 @@ namespace HealthMonitoring.Reporting; public sealed record ServerStatusDashboardModel( - string ServerId, string ServerName, string OverallStatus, DateTimeOffset UpdatedAtUtc, @@ -25,6 +24,6 @@ public static class ServerStatusDashboardMapper public static ServerStatusDashboardModel Map(ServerStatus status) { var checks = JsonSerializer.Deserialize>(status.ChecksJson, JsonOptions) ?? []; - return new ServerStatusDashboardModel(status.ServerId, status.ServerName, status.OverallStatus, status.UpdatedAtUtc, checks); + return new ServerStatusDashboardModel(status.ServerName, status.OverallStatus, status.UpdatedAtUtc, checks); } } diff --git a/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 b/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 index d7a467b..c299ffc 100644 --- a/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 +++ b/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 @@ -1205,25 +1205,23 @@ function Send-ServerStatus { $requestUri = $null try { - $serverId = [string] $settings.ServerId $baseUrl = [string] $settings.BaseUrl $path = [string] $settings.SupportChecksPath - if ([string]::IsNullOrWhiteSpace($serverId)) { throw 'ServerStatusApi.ServerId must be configured.' } if ([string]::IsNullOrWhiteSpace($baseUrl)) { throw 'ServerStatusApi.BaseUrl must be configured.' } if ([string]::IsNullOrWhiteSpace($path)) { throw 'ServerStatusApi.SupportChecksPath must be configured.' } $serverName = if ($settings.PSObject.Properties['ServerName'] -and -not [string]::IsNullOrWhiteSpace([string] $settings.ServerName)) { - [string] $settings.ServerName + ([string] $settings.ServerName).Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { - [string] $env:COMPUTERNAME + ([string] $env:COMPUTERNAME).Trim() } else { - 'Unknown Server' + throw 'ServerStatusApi.ServerName must be configured when COMPUTERNAME is unavailable.' } - $encodedServerId = [Uri]::EscapeDataString($serverId) - $requestPath = $path.Replace('{serverId}', $encodedServerId) + $encodedServerName = [Uri]::EscapeDataString($serverName) + $requestPath = $path.Replace('{serverName}', $encodedServerName) $requestUri = "$($baseUrl.TrimEnd('/'))/$($requestPath.TrimStart('/'))" $checks = @($Report.Checks | ForEach-Object { [ordered]@{ @@ -1238,7 +1236,6 @@ function Send-ServerStatus { } }) $payload = [ordered]@{ - serverName = $serverName overallStatus = [string] $Report.OverallStatus checks = $checks } diff --git a/ScheduledTasks/DailySupportCheck/README.md b/ScheduledTasks/DailySupportCheck/README.md index b17deec..01f2a18 100644 --- a/ScheduledTasks/DailySupportCheck/README.md +++ b/ScheduledTasks/DailySupportCheck/README.md @@ -36,8 +36,7 @@ The default configuration file is `daily-support-check.json` beside the script: "ServerStatusApi": { "Enabled": true, "BaseUrl": "http://localhost:9620", - "SupportChecksPath": "/api/services/{serverId}/support-checks", - "ServerId": "transaction-processor", + "SupportChecksPath": "/api/servers/{serverName}/support-checks", "ServerName": "", "TimeoutSeconds": 30 }, @@ -187,7 +186,7 @@ Reports are always written locally. When `ServerStatusApi.Enabled` is `true`, th Copy-Item .\ScheduledTasks\DailySupportCheck\daily-support-check.local.example.json .\ScheduledTasks\DailySupportCheck\daily-support-check.local.json ``` -Configure `ServerStatusApi.BaseUrl`, `ServerStatusApi.ServerId`, and optionally `ServerStatusApi.ServerName`. If `ServerName` is empty, the machine's `COMPUTERNAME` is used. The server ID is URL-encoded and substituted into `{serverId}` in `SupportChecksPath`. The request body contains only `serverName`, `overallStatus`, and the API-compatible `checks` array. +Configure `ServerStatusApi.BaseUrl` and optionally `ServerStatusApi.ServerName`. If `ServerName` is empty, the machine's `COMPUTERNAME` is used; set a stable name explicitly if the machine name may change. The server name is URL-encoded and substituted into `{serverName}` in `SupportChecksPath`. The request body contains only `overallStatus` and the API-compatible `checks` array. ## Tests diff --git a/ScheduledTasks/DailySupportCheck/Tests/Invoke-DailySupportCheck.Tests.ps1 b/ScheduledTasks/DailySupportCheck/Tests/Invoke-DailySupportCheck.Tests.ps1 index f1ac860..3e173ed 100644 --- a/ScheduledTasks/DailySupportCheck/Tests/Invoke-DailySupportCheck.Tests.ps1 +++ b/ScheduledTasks/DailySupportCheck/Tests/Invoke-DailySupportCheck.Tests.ps1 @@ -239,15 +239,14 @@ Describe 'Daily support check' { $transport.Paths.HtmlPath | Should -Be 'report.html' } - It 'posts the API-compatible server status payload with the server id in the URL' { + It 'posts the API-compatible server status payload with the server name in the URL' { $script:captured = $null $configuration = [pscustomobject]@{ ServerStatusApi = [pscustomobject]@{ Enabled = $true BaseUrl = 'http://health-monitoring.test:9820' - SupportChecksPath = '/api/services/{serverId}/support-checks' - ServerId = 'transaction processor' - ServerName = 'TRANSACTION-SERVER-01' + SupportChecksPath = '/api/servers/{serverName}/support-checks' + ServerName = 'TRANSACTION SERVER 01' TimeoutSeconds = 15 } } @@ -259,7 +258,7 @@ Describe 'Daily support check' { Payload = $requestPayload TimeoutSeconds = $timeoutSeconds } - [pscustomobject]@{ serverId = 'transaction processor'; overallStatus = 'Warning' } + [pscustomobject]@{ serverName = 'TRANSACTION SERVER 01'; overallStatus = 'Warning' } } $report = [pscustomobject]@{ OverallStatus = 'Warning' @@ -280,12 +279,13 @@ Describe 'Daily support check' { $transport = Send-ServerStatus -Report $report -Configuration $configuration -ApiProvider $provider $transport.Status | Should -Be 'Sent' - $script:captured.Uri | Should -Be 'http://health-monitoring.test:9820/api/services/transaction%20processor/support-checks' + $script:captured.Uri | Should -Be 'http://health-monitoring.test:9820/api/servers/TRANSACTION%20SERVER%2001/support-checks' $script:captured.TimeoutSeconds | Should -Be 15 - $script:captured.Payload.serverName | Should -Be 'TRANSACTION-SERVER-01' $script:captured.Payload.overallStatus | Should -Be 'Warning' $script:captured.Payload.checks[0].name | Should -Be 'Disk Space' $script:captured.Payload.checks[0].details[0].Drive | Should -Be 'C:' + $script:captured.Payload.Keys | Should -Not -Contain 'serverName' + $script:captured.Payload.Keys | Should -Not -Contain 'serverId' } It 'sends a failure notification email when API delivery fails' { @@ -322,7 +322,7 @@ Describe 'Daily support check' { $apiTransport = [pscustomobject]@{ Transport = 'ServerStatusApi' Status = 'Failed' - Uri = 'http://localhost:9620/api/services/transaction-processor/support-checks' + Uri = 'http://localhost:9620/api/servers/TRANSACTION-SERVER-01/support-checks' Message = 'Connection refused.' } $emailProvider = { @@ -344,7 +344,7 @@ Describe 'Daily support check' { $notification.Transport | Should -Be 'FailureNotificationEmail' $script:captured.Payload.subject | Should -Match 'API delivery failed' $script:captured.Payload.textContent | Should -Match 'Connection refused' - $script:captured.Payload.textContent | Should -Match 'http://localhost:9620/api/services/transaction-processor/support-checks' + $script:captured.Payload.textContent | Should -Match 'http://localhost:9620/api/servers/TRANSACTION-SERVER-01/support-checks' $script:captured.Payload.textContent | Should -Match 'Parked messages were found' $script:captured.Payload.attachments.name | Should -Contain 'failure-report.json' $script:captured.Payload.attachments.name | Should -Contain 'failure-report.html' @@ -363,8 +363,7 @@ Describe 'Daily support check' { "ServerStatusApi": { "Enabled": true, "BaseUrl": "http://health-monitoring.test:9620", - "SupportChecksPath": "/api/services/{serverId}/support-checks", - "ServerId": "transaction-processor", + "SupportChecksPath": "/api/servers/{serverName}/support-checks", "ServerName": "TRANSACTION-SERVER-01", "TimeoutSeconds": 10 }, diff --git a/ScheduledTasks/DailySupportCheck/daily-support-check.json b/ScheduledTasks/DailySupportCheck/daily-support-check.json index 6ea8524..c741d67 100644 --- a/ScheduledTasks/DailySupportCheck/daily-support-check.json +++ b/ScheduledTasks/DailySupportCheck/daily-support-check.json @@ -3,8 +3,7 @@ "ServerStatusApi": { "Enabled": true, "BaseUrl": "http://localhost:9620", - "SupportChecksPath": "/api/services/{serverId}/support-checks", - "ServerId": "transaction-processor", + "SupportChecksPath": "/api/servers/{serverName}/support-checks", "ServerName": "", "TimeoutSeconds": 30 }, diff --git a/ScheduledTasks/DailySupportCheck/daily-support-check.local.example.json b/ScheduledTasks/DailySupportCheck/daily-support-check.local.example.json index 852f0d4..4607ca7 100644 --- a/ScheduledTasks/DailySupportCheck/daily-support-check.local.example.json +++ b/ScheduledTasks/DailySupportCheck/daily-support-check.local.example.json @@ -2,8 +2,7 @@ "ServerStatusApi": { "Enabled": true, "BaseUrl": "http://localhost:9620", - "SupportChecksPath": "/api/services/{serverId}/support-checks", - "ServerId": "transaction-processor", + "SupportChecksPath": "/api/servers/{serverName}/support-checks", "ServerName": "", "TimeoutSeconds": 30 }, diff --git a/docs/superpowers/plans/2026-11-07-server-name-support-checks.md b/docs/superpowers/plans/2026-11-07-server-name-support-checks.md new file mode 100644 index 0000000..b406c2b --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-server-name-support-checks.md @@ -0,0 +1,81 @@ +# Server-Name Support Checks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the service-keyed support-check endpoint with a server-name-keyed endpoint and update storage, sender, and UI identity accordingly. + +**Architecture:** `ServerName` becomes the unique identity in `ServerStatus`, and `POST /api/servers/{serverName}/support-checks` upserts reports directly without consulting monitored services. The Daily Support Check sender calls that route; the existing Server Checks page continues reading the latest persisted report. + +**Tech Stack:** ASP.NET Core minimal APIs, EF Core with SQL Server, C#, PowerShell, JSON configuration. + +**Spec:** `docs/superpowers/specs/2026-11-07-server-name-support-checks-design.md` + +## Global Constraints + +- Server names are unique and stable identifiers; a renamed server is a new identity. +- Remove `POST /api/services/{serverId}/support-checks` with no compatibility alias. +- Report ingestion must not require a matching monitored service. +- Preserve report contents during migration and fail clearly if existing server names collide. +- Keep the UI's existing latest-report-overall behavior; do not add a server registry or selector. +- Do not add or run tests; update existing test fixtures/assertions only as needed to keep them aligned and compilable. + +## Review Focus + +- Duplicate existing names must stop migration with an actionable error, without deleting reports. +- Rolling back after reports exist must fail clearly instead of inventing replacement legacy IDs. +- A valid report must save for a server name absent from the monitored-service registry. +- URL-encoded names with spaces must reach the intended server identity after route decoding. +- Missing or whitespace-only configured/runtime server names must not be written under a shared fallback identity. +- The Sender must not include the removed `serverId` or duplicate `serverName` in the JSON body. + +--- + +## File Map + +- `HealthMonitoring/HealthMonitoring/Api/ServerStatusEndpoints.cs` — replace the route and remove service existence lookup; build response location from the new route. +- `HealthMonitoring/HealthMonitoring/Api/ServerStatusContracts.cs` — remove the body server name and obsolete response `ServerId`; validate the route server name. +- `HealthMonitoring/HealthMonitoring/Domain/ServerStatus.cs` — remove `ServerId`; retain `ServerName` as the identity. +- `HealthMonitoring/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs` and `HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringRepository.cs` — query and upsert status by server name. +- `HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs`, `HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs`, and a new migration — unique index on `ServerName`, remove `ServerId`, reject duplicate names before applying the index. +- `HealthMonitoring/HealthMonitoring/Reporting/ServerStatusDashboardModels.cs` (contains both dashboard model and mapper) and `HealthMonitoring/HealthMonitoring/Components/Pages/ServerChecks.razor` — remove obsolete ID display and use server name as identity. +- Existing related tests under `HealthMonitoring/HealthMonitoring.Tests/Api`, `Persistence`, and `Reporting` — update references/assertions to the new model and contract without adding test cases or running tests. +- `ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1` — construct the new route from configured `ServerName` or `COMPUTERNAME`, and remove identity fields from the body. +- `ScheduledTasks/DailySupportCheck/daily-support-check.json`, `ScheduledTasks/DailySupportCheck/daily-support-check.local.example.json`, `ScheduledTasks/DailySupportCheck/README.md`, and `ScheduledTasks/DailySupportCheck/Tests/Invoke-DailySupportCheck.Tests.ps1` — rename configuration path token/settings and update existing fixtures/assertions. + +## Task 1: Make the API and persisted report identity the server name + +**Files:** API contracts and endpoints, `ServerStatus`, repository interface/implementation, DbContext, migration/snapshot, and existing related C# test references. + +**Interfaces:** `GetServerStatusAsync(string serverName, CancellationToken)` and `UpsertServerStatusAsync(ServerStatus status, CancellationToken)` both key by the trimmed server name. The request body has `OverallStatus` and `Checks`; `ServerName` comes from the route. + +- [x] Replace the mapped route with `POST /api/servers/{serverName}/support-checks`; remove the service lookup; keep 201-on-create and 200-on-update behavior; set the Created location to the new path. +- [x] Move server-name validation to the route value, trim before persistence and lookup, and retain all current check validation and overall-status calculation. +- [x] Remove `ServerId` from `ServerStatus`; make repository get/upsert methods use `ServerName` and normalize names. +- [x] Change the EF model to require `ServerName` and apply a unique index to it. Add a migration that first throws an actionable SQL error if duplicate server names exist, then drops the old `ServerId` index/column and creates the unique `ServerName` index. Make `Down` refuse rollback with existing report rows because the old ID mapping cannot be reconstructed. Update the model snapshot. +- [x] Update existing C# fixtures and assertions for removed `ServerId` and body `ServerName` members. +- [x] Build `HealthMonitoring/HealthMonitoring/HealthMonitoring.csproj` and `HealthMonitoring/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj` to check production and fixture compilation; do not run tests. + +## Task 2: Update Daily Support Check delivery and configuration + +**Files:** PowerShell sender, JSON config examples, README, existing sender tests. + +**Interfaces:** `ServerStatusApi.ServerName` is the configured stable name; when empty, use `COMPUTERNAME`. Build `SupportChecksPath` by URL-encoding the name into `{serverName}`. JSON body contains only `overallStatus` and `checks`. + +- [x] Replace `ServerId` and `{serverId}` use with `ServerName` and `{serverName}`; retain configured-name then computer-name fallback, but fail delivery clearly if both are blank (remove `Unknown Server`). +- [x] Update both JSON config files and README examples/instructions to the new path, setting, and body contract. +- [x] Update existing PowerShell sender fixtures/assertions for URL encoding and payload shape; implement clear delivery failure when both name sources are blank, without adding or running tests. +- [x] Parse the script and updated sender fixture with PowerShell's parser and validate both JSON configuration files. + +## Task 3: Align dashboard models and display + +**Files:** server status dashboard models/mapper, Server Checks page, and existing related C# fixture/assertion. + +- [x] Remove the obsolete `ServerId` member from dashboard models/mapper; display only `ServerName` as the identity. +- [x] Preserve the page's current latest-report-overall query and existing check rendering. +- [x] Build the HealthMonitoring app and test project to check compilation; do not run tests. + +## Task 4: Final consistency review + +- [x] Search the repository for stale caller routes/settings and server-status uses of `ServerId`; remove stale references while leaving historical migration references intact. +- [x] Review `git diff --check` and the complete diff for migration safety, route/body consistency, and adherence to the spec. +- [x] Report build/parse/JSON-validation outcomes and any existing-name collision remediation required before deploying the migration. diff --git a/docs/superpowers/specs/2026-11-07-server-name-support-checks-design.md b/docs/superpowers/specs/2026-11-07-server-name-support-checks-design.md new file mode 100644 index 0000000..797f3d2 --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-server-name-support-checks-design.md @@ -0,0 +1,57 @@ +# Server-name support checks design + +## Intent + +Accept daily support-check reports for a server without requiring that server to be registered as a monitored service. A server name is the stable, unique identity for a report. This fixes the current coupling where the ingestion handler looks up `serverId` in the monitored-service registry before saving an otherwise independent server report. + +## Goals + +- Replace `POST /api/services/{serverId}/support-checks` with `POST /api/servers/{serverName}/support-checks`. +- Use the server name as the report identity in the API and persistence layer. +- Remove the old route without a compatibility alias. +- Update the Daily Support Check sender, its configuration examples, and its documentation to use the new route. +- Keep the Server Checks UI able to display the latest saved report. + +## Non-goals + +- Register servers as monitored services or require service registration. +- Add a server registry, server list, per-server history, or a UI for selecting among servers. +- Change report check fields, status calculation, or the UI's existing behavior of showing the latest report overall. + +## Contract + +The replacement endpoint is `POST /api/servers/{serverName}/support-checks`. The decoded path segment is the canonical server name. The JSON body contains `overallStatus` and `checks`; it no longer contains `serverName` or `serverId`. The server calculates `overallStatus` from the check statuses, as it does today. Check validation and the existing response behavior remain: `201 Created` for the first report for a server and `200 OK` for later reports. + +The endpoint does not query `IHealthMonitoringRepository.GetServiceAsync`. It validates that the server name is non-empty and within the persistence limit, then creates or updates the report for that name. Trim surrounding whitespace before storing and matching the name, including in repository methods. A missing/blank name or invalid check data returns a validation error. + +## Persistence and migration + +Use `ServerName` as the unique key for `ServerStatus` upserts. Remove the obsolete `ServerId` property/column and update API, reporting, and UI models to use `ServerName` as the identity. Add an EF Core migration that removes the old unique index on `ServerId` and enforces uniqueness on `ServerName`, preserving existing report contents. The migration must detect duplicate existing server names and fail with an actionable error rather than silently discarding a report; operators must reconcile those rows before retrying. + +Server names are unique and stable by contract. A renamed server is a new identity and will have no prior report under its new name. + +The migration's `Down` operation must refuse automatic rollback when report rows exist, because the removed legacy `ServerId` values cannot be reconstructed from server names. Its error must instruct operators to restore a pre-migration backup or manually restore the old ID mapping before retrying. + +## Daily Support Check sender + +Replace `ServerStatusApi.ServerId` and the `{serverId}` path token with `ServerStatusApi.ServerName` and `{serverName}` in the endpoint path. Keep the current configuration behavior of using the configured name or falling back to the machine's `COMPUTERNAME`; do not use the shared `Unknown Server` fallback because it can merge reports from unrelated machines. If neither name is available, report the API submission as failed with a clear configuration error. Send only `overallStatus` and `checks` in the JSON body. + +Update `daily-support-check.json`, `daily-support-check.local.example.json`, the README, and sender tests. Remove coverage and documentation for the old route and `ServerId` setting. + +## UI behavior + +The Server Checks page continues reading stored reports through `IDashboardQueryService`; it does not call the ingestion route. Update its display model to show the server name as the identity and remove the obsolete server ID line. It continues showing the latest report overall; per-server selection is outside this change. + +## Approaches considered + +1. **New server-name route with server name as the stored key (chosen).** `POST /api/servers/{serverName}/support-checks`; remove the old route and `ServerId` identity. This makes the API and persisted identity match the domain and avoids duplicate identity fields in the body. +2. **New collection route with the server name in the body.** `POST /api/servers/support-checks`; this avoids a path/body duplication but leaves the target identity less visible in the URL. +3. **Keep the old service route and remove only the service lookup.** This is the smallest implementation, but leaves a service-shaped URL for server-owned data and retains the misleading `serverId` concept. It was rejected. + +## Constraints and implementation calls + +- Existing report JSON and check validation remain compatible. +- No compatibility endpoint is retained; callers must update with the API. +- The storage migration preserves rows and fails visibly if current server names are duplicated. +- The Daily Support Check sender is the known in-repository caller and must change in the same implementation. +- The work is a small API and caller change with a focused persistence migration; the implementer may settle routine naming details consistent with surrounding code. From 22177709373e6885f12767122cc4e5b2ced4cdd2 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 7 Nov 2026 14:35:31 +0000 Subject: [PATCH 2/2] refactor: simplify daily support check script --- .../Invoke-DailySupportCheck.ps1 | 82 +++++++++---------- ...ily-support-check-script-simplification.md | 38 +++++++++ ...port-check-script-simplification-design.md | 36 ++++++++ 3 files changed, 111 insertions(+), 45 deletions(-) create mode 100644 docs/superpowers/plans/2026-11-07-daily-support-check-script-simplification.md create mode 100644 docs/superpowers/specs/2026-11-07-daily-support-check-script-simplification-design.md diff --git a/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 b/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 index c299ffc..e8cabc8 100644 --- a/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 +++ b/ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1 @@ -146,6 +146,23 @@ function Merge-SupportConfiguration { $Base } +function Assert-HttpEndpointConfiguration { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $SectionName, + [Parameter(Mandatory)] [object] $Settings + ) + + $endpointUri = $null + if (-not [uri]::TryCreate([string] $Settings.BaseUrl, [UriKind]::Absolute, [ref] $endpointUri)) { + throw "$SectionName.BaseUrl must be an absolute URI." + } + + if ([int] $Settings.TimeoutSeconds -le 0) { + throw "$SectionName.TimeoutSeconds must be greater than zero." + } +} + function Get-SupportConfiguration { [CmdletBinding()] param([Parameter(Mandatory)] [string] $Path) @@ -187,14 +204,7 @@ function Get-SupportConfiguration { return $configuration } - $healthMonitoringUri = $null - if (-not [uri]::TryCreate([string] $configuration.HealthMonitoring.BaseUrl, [UriKind]::Absolute, [ref] $healthMonitoringUri)) { - throw 'HealthMonitoring.BaseUrl must be an absolute URI.' - } - - if ([int] $configuration.HealthMonitoring.TimeoutSeconds -le 0) { - throw 'HealthMonitoring.TimeoutSeconds must be greater than zero.' - } + Assert-HttpEndpointConfiguration -SectionName 'HealthMonitoring' -Settings $configuration.HealthMonitoring } if ($configuration.PSObject.Properties['SubscriptionService']) { @@ -202,14 +212,7 @@ function Get-SupportConfiguration { return $configuration } - $subscriptionServiceUri = $null - if (-not [uri]::TryCreate([string] $configuration.SubscriptionService.BaseUrl, [UriKind]::Absolute, [ref] $subscriptionServiceUri)) { - throw 'SubscriptionService.BaseUrl must be an absolute URI.' - } - - if ([int] $configuration.SubscriptionService.TimeoutSeconds -le 0) { - throw 'SubscriptionService.TimeoutSeconds must be greater than zero.' - } + Assert-HttpEndpointConfiguration -SectionName 'SubscriptionService' -Settings $configuration.SubscriptionService } if ($configuration.PSObject.Properties['KurrentDbProjections']) { @@ -217,14 +220,7 @@ function Get-SupportConfiguration { return $configuration } - $kurrentDbUri = $null - if (-not [uri]::TryCreate([string] $configuration.KurrentDbProjections.BaseUrl, [UriKind]::Absolute, [ref] $kurrentDbUri)) { - throw 'KurrentDbProjections.BaseUrl must be an absolute URI.' - } - - if ([int] $configuration.KurrentDbProjections.TimeoutSeconds -le 0) { - throw 'KurrentDbProjections.TimeoutSeconds must be greater than zero.' - } + Assert-HttpEndpointConfiguration -SectionName 'KurrentDbProjections' -Settings $configuration.KurrentDbProjections $hasUsername = $configuration.KurrentDbProjections.PSObject.Properties['Username'] -and -not [string]::IsNullOrWhiteSpace([string] $configuration.KurrentDbProjections.Username) $hasPasswordEnvironmentVariable = $configuration.KurrentDbProjections.PSObject.Properties['PasswordEnvironmentVariable'] -and -not [string]::IsNullOrWhiteSpace([string] $configuration.KurrentDbProjections.PasswordEnvironmentVariable) @@ -944,6 +940,20 @@ function ConvertTo-HtmlEncodedText { [System.Net.WebUtility]::HtmlEncode([string] $Value) } +function Get-SupportStatusIcon { + param([string] $Status) + + switch ($Status) { + 'Passed' { '🟢' } + 'Healthy' { '🟢' } + 'Warning' { '🟠' } + 'Degraded' { '🟠' } + 'Failed' { '🔴' } + 'Unhealthy' { '🔴' } + default { '⚪' } + } +} + function Write-SupportReports { [CmdletBinding()] param( @@ -973,12 +983,7 @@ function Write-SupportReports { $summaryText = $summaryText.Substring(0, 157).TrimEnd() + '…' } $summary = [System.Net.WebUtility]::HtmlEncode($summaryText) - $statusIcon = switch ([string] $check.Status) { - 'Passed' { '🟢' } - 'Warning' { '🟠' } - 'Failed' { '🔴' } - default { '⚪' } - } + $statusIcon = Get-SupportStatusIcon -Status ([string] $check.Status) [void] $rows.AppendLine("$name$statusIcon $status$summary") } @@ -994,12 +999,7 @@ function Write-SupportReports { foreach ($subscription in $subscriptionItems) { $detailStatus = ConvertTo-HtmlEncodedText $subscription.Status $detailStatusClass = [System.Net.WebUtility]::HtmlEncode("status-$([string] $subscription.Status)") - $detailStatusIcon = switch ([string] $subscription.Status) { - 'Passed' { '🟢' } - 'Warning' { '🟠' } - 'Failed' { '🔴' } - default { '⚪' } - } + $detailStatusIcon = Get-SupportStatusIcon -Status ([string] $subscription.Status) $reasonParts = @( if ($subscription.PSObject.Properties['operationalReason'] -and -not [string]::IsNullOrWhiteSpace([string] $subscription.OperationalReason)) { "Operational: $($subscription.OperationalReason)" } if ($subscription.PSObject.Properties['runtimeFailureReason'] -and -not [string]::IsNullOrWhiteSpace([string] $subscription.RuntimeFailureReason)) { "Runtime: $($subscription.RuntimeFailureReason)" } @@ -1022,15 +1022,7 @@ function Write-SupportReports { foreach ($service in $healthMonitoringItems) { $detailStatus = ConvertTo-HtmlEncodedText $service.Status $detailStatusClass = [System.Net.WebUtility]::HtmlEncode("status-$([string] $service.Status)") - $detailStatusIcon = switch ([string] $service.Status) { - 'Passed' { '🟢' } - 'Healthy' { '🟢' } - 'Warning' { '🟠' } - 'Degraded' { '🟠' } - 'Failed' { '🔴' } - 'Unhealthy' { '🔴' } - default { '⚪' } - } + $detailStatusIcon = Get-SupportStatusIcon -Status ([string] $service.Status) [void] $healthMonitoringDetails.AppendLine("$(ConvertTo-HtmlEncodedText $service.ServiceId)$(ConvertTo-HtmlEncodedText $service.Name)$detailStatusIcon $detailStatus$(ConvertTo-HtmlEncodedText $service.LastObservedAtUtc)$(ConvertTo-HtmlEncodedText $service.LastError)") } [void] $healthMonitoringDetails.AppendLine('') diff --git a/docs/superpowers/plans/2026-11-07-daily-support-check-script-simplification.md b/docs/superpowers/plans/2026-11-07-daily-support-check-script-simplification.md new file mode 100644 index 0000000..f460d3d --- /dev/null +++ b/docs/superpowers/plans/2026-11-07-daily-support-check-script-simplification.md @@ -0,0 +1,38 @@ +# Daily Support Check Script Simplification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove repeated status-icon and HTTP endpoint configuration validation logic without changing script behavior. + +**Architecture:** Keep the script standalone. Add one status-icon helper covering the current status aliases and one HTTP endpoint configuration helper used by the three sections that currently validate both `BaseUrl` and `TimeoutSeconds`. + +**Tech Stack:** PowerShell. + +**Spec:** `docs/superpowers/specs/2026-11-07-daily-support-check-script-simplification-design.md` + +## Global Constraints + +- Keep `Invoke-DailySupportCheck.ps1` as a single self-contained script. +- Preserve current validation order, messages, optional-section behavior, and disabled-section early returns. +- Preserve icon mappings: `Passed`/`Healthy` → green, `Warning`/`Degraded` → orange, `Failed`/`Unhealthy` → red, and all other values → white. +- Do not change any check's request, status calculation, fallback, report, or email behavior. +- Do not add or run tests. + +## Review Focus + +- A disabled optional endpoint section must retain the existing early return behavior. +- Invalid endpoint URI and non-positive timeout errors must preserve their current section-specific messages. +- Status values with and without aliases must render the same icon, including unknown values. + +--- + +## Task 1: Extract repeated status and endpoint configuration helpers + +**Files:** `ScheduledTasks/DailySupportCheck/Invoke-DailySupportCheck.ps1` + +**Interfaces:** `Get-SupportStatusIcon([string] $Status) -> [string]`; `Assert-HttpEndpointConfiguration([string] $SectionName, [object] $Settings) -> void`. + +- [x] Add `Get-SupportStatusIcon` with the approved mapping and replace only the three existing HTML status-icon switches. +- [x] Add `Assert-HttpEndpointConfiguration`; preserve the exact URI and timeout validation logic and error messages. +- [x] Replace the repeated validation blocks for `HealthMonitoring`, `SubscriptionService`, and `KurrentDbProjections` with helper calls while keeping their existing property checks, ordering, domain-specific validation, and early returns. +- [x] Parse the updated script with PowerShell's parser, review the diff for behavior changes, and run `git diff --check`; do not run tests. diff --git a/docs/superpowers/specs/2026-11-07-daily-support-check-script-simplification-design.md b/docs/superpowers/specs/2026-11-07-daily-support-check-script-simplification-design.md new file mode 100644 index 0000000..f62c9af --- /dev/null +++ b/docs/superpowers/specs/2026-11-07-daily-support-check-script-simplification-design.md @@ -0,0 +1,36 @@ +# Daily Support Check script simplification + +## Intent + +Make repeated presentation and configuration-validation logic easier to maintain in `Invoke-DailySupportCheck.ps1` without changing report output, configuration behavior, or how the standalone script is deployed. + +## Goals + +- Replace the three duplicated status-to-icon switches in HTML report generation with one helper that preserves the current mappings: `Passed`/`Healthy` → green, `Warning`/`Degraded` → orange, `Failed`/`Unhealthy` → red, and all other values → white. +- Replace the repeated absolute-URI and positive-timeout validation for `HealthMonitoring`, `SubscriptionService`, and `KurrentDbProjections` with one helper. +- Preserve the current validation order, messages, optional-section behavior, and early return when a present section is disabled. +- Keep `Invoke-DailySupportCheck.ps1` as a single self-contained script. + +## Non-goals + +- Split the script into modules or change installation/sourcing requirements. +- Change any check's request, status calculation, fallback, report or email behavior. +- Rewrite the entire configuration validator as a table-driven framework. +- Change KurrentDB write-activity validation, which has no matching timeout requirement. + +## Chosen approach + +Add a small `Get-SupportStatusIcon` helper for the existing icon mappings and an `Assert-HttpEndpointConfiguration` helper that accepts the section name, base URL, and timeout. Call the endpoint helper from the three existing validation blocks while leaving their order, enable checks, optional settings, and early returns in place. Replace only the repeated icon switches with helper calls; leave HTML structure and text encoding as they are. + +## Alternatives considered + +1. **Extract the repeated helpers in the existing script (chosen).** Reduces duplicated logic with minimal behavioral and packaging risk. +2. **Split checks, reporting, and transports into separate files.** Could improve navigation further, but changes how callers load and deploy the script and exceeds this focused cleanup. +3. **Rewrite all validation as declarative metadata.** More compact, but risks changing validation order and disabled-section short-circuit behavior for little added value. + +## Constraints and implementation calls + +- Preserve current user-visible messages and configuration behavior byte-for-byte where applicable. +- Keep the current script API and deployment layout. +- The existing PR #843 is the target branch and should receive this cleanup as a follow-up commit. +- Do not add or run tests; use static inspection and syntax parsing if needed.