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
6 changes: 6 additions & 0 deletions .github/workflows/createrelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ jobs:
name: scheduledtasks
path: ScheduledTasks

- name: Build and Publish Nuget Packages
if: ${{ github.event.release.prerelease == false }}
run: |
dotnet pack "HealthMonitoring.Client\HealthMonitoring.Client.csproj" /p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output Nugets
dotnet nuget push Nugets/HealthMonitoring.Client.${{ steps.get_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }} --skip-duplicate

buildsqlagentjobdeploymenttool:
name: "Build SQL Agent Job Deployment Tool"
runs-on: windows-latest
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,23 @@ jobs:
'./ScheduledTasks/ReplayParkedQueue/Tests'
'./ScheduledTasks/SettlementProcessing/Tests'
) -CI

health-monitoring-tests:
name: "Run Health Monitoring Unit Tests"
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Install NET 10
uses: actions/setup-dotnet@v4.0.1
with:
dotnet-version: '10.0.x'

- name: Restore Health Monitoring solution
run: dotnet restore HealthMonitoring/HealthMonitoring.sln

- name: Run Health Monitoring unit tests
run: dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName!~ConfigurationTests"
22 changes: 22 additions & 0 deletions .github/workflows/pushtomain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,28 @@ jobs:
name: scheduledtasks
path: ScheduledTasks

- name: 'Get Previous tag'
id: get-latest-tag
uses: actions-ecosystem/action-get-latest-tag@v1
with:
semver_only: true

- name: 'Bump Version'
id: bump-semver
uses: actions-ecosystem/action-bump-semver@v1
with:
current_version: ${{ steps.get-latest-tag.outputs.tag }}
level: patch

- name: Print Version
id: next_version
run: echo ::set-output name=VERSION::${{ steps.bump-semver.outputs.new_version }}.$GITHUB_RUN_NUMBER

- name: Build and Publish Nuget Packages
run: |
dotnet pack "HealthMonitoring.Client\HealthMonitoring.Client.csproj" /p:PackageVersion=${{ steps.next_version.outputs.VERSION }} --output Nugets
dotnet nuget push Nugets/HealthMonitoring.Client.${{ steps.next_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }}

buildsqlagentjobdeploymenttool:
name: "Build SQL Agent Job Deployment Tool"
runs-on: windows-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,5 @@ MigrationBackup/
/TransactionProcessing.MerchantPos/merchant.db-shm
/TransactionProcessing.MerchantPos/merchant.db-wal
/TransactionProcessor.SystemSetupTool/Properties/launchSettings.json
# Codex implementation worktrees
.worktrees/
41 changes: 41 additions & 0 deletions .superpowers/brainstorm/codex-20260918/content/overview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<h2>Monitoring dashboard direction</h2>
<p class="subtitle">Overview first, with a focused service detail view available from each card.</p>

<div class="mockup">
<div class="mockup-header">HealthWatch · Overview</div>
<div class="mockup-body" style="background:#f4f7f8;padding:18px;color:#17212b;font-family:Arial,sans-serif">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div><div style="font-size:20px;font-weight:700">Services</div><div style="font-size:12px;color:#70808c">Last refresh: just now · 12 monitored</div></div>
<div style="display:flex;gap:8px"><button class="mock-button">Add service</button><button class="mock-button">Configure</button></div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px">
<div style="background:white;border-radius:10px;padding:12px;border-left:4px solid #34c77b"><div style="font-size:11px;color:#70808c">HEALTHY</div><div style="font-size:24px;font-weight:700">9</div></div>
<div style="background:white;border-radius:10px;padding:12px;border-left:4px solid #e7a93b"><div style="font-size:11px;color:#70808c">DEGRADED</div><div style="font-size:24px;font-weight:700">2</div></div>
<div style="background:white;border-radius:10px;padding:12px;border-left:4px solid #e34a59"><div style="font-size:11px;color:#70808c">UNHEALTHY</div><div style="font-size:24px;font-weight:700">1</div></div>
<div style="background:white;border-radius:10px;padding:12px;border-left:4px solid #9aa6ae"><div style="font-size:11px;color:#70808c">OPEN INCIDENTS</div><div style="font-size:24px;font-weight:700">1</div></div>
</div>
<div style="background:white;border-radius:10px;padding:0;overflow:hidden">
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1.4fr;padding:12px 14px;background:#eef2f3;font-size:11px;font-weight:700;color:#70808c">
<div>SERVICE</div><div>STATUS</div><div>RESPONSE</div><div>UPTIME (30D)</div><div>LAST CHECK</div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1.4fr;padding:15px 14px;border-top:1px solid #edf0f1;align-items:center;font-size:13px"><div><b>Merchant POS</b><div style="font-size:11px;color:#70808c">Production · /health</div></div><div style="color:#22a968;font-weight:700">● Healthy</div><div>8 ms</div><div>99.99%</div><div>18 Sep, 08:14</div></div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1.4fr;padding:15px 14px;border-top:1px solid #edf0f1;align-items:center;font-size:13px"><div><b>Merchant File Processor</b><div style="font-size:11px;color:#70808c">Production · /health</div></div><div style="color:#bb7a11;font-weight:700">● Degraded</div><div>31 ms</div><div>99.82%</div><div>18 Sep, 08:14</div></div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1.4fr;padding:15px 14px;border-top:1px solid #edf0f1;align-items:center;font-size:13px"><div><b>Transaction API</b><div style="font-size:11px;color:#70808c">Staging · /health</div></div><div style="color:#d53849;font-weight:700">● Unhealthy</div><div>—</div><div>98.41%</div><div>Incident open · 6m</div></div>
</div>
</div>
</div>

<div class="section">
<h3>Service detail view</h3>
<div class="mockup">
<div class="mockup-header">Merchant File Processor · Production</div>
<div class="mockup-body" style="background:#f4f7f8;padding:18px;color:#17212b;font-family:Arial,sans-serif">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"><div><div style="font-size:18px;font-weight:700">● Degraded</div><div style="font-size:12px;color:#70808c">Last checked 18 Sep, 08:14:22 · 31 ms</div></div><button class="mock-button">Edit service</button></div>
<div style="display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:10px;margin-bottom:12px"><div style="background:white;border-radius:10px;padding:12px"><div style="font-size:11px;color:#70808c">UPTIME (30 DAYS)</div><div style="font-size:21px;font-weight:700">99.82%</div></div><div style="background:white;border-radius:10px;padding:12px"><div style="font-size:11px;color:#70808c">CURRENT INCIDENT</div><div style="font-size:21px;font-weight:700">12m 18s</div></div><div style="background:white;border-radius:10px;padding:12px"><div style="font-size:11px;color:#70808c">AVG RESPONSE (24H)</div><div style="font-size:21px;font-weight:700">24.6 ms</div></div></div>
<div style="background:white;border-radius:10px;padding:12px;margin-bottom:12px"><div style="font-size:11px;color:#70808c;margin-bottom:8px">STATUS TIMELINE · LAST 24 HOURS</div><div style="display:flex;gap:2px;height:22px"><div style="flex:22;background:#34c77b;border-radius:4px"></div><div style="flex:3;background:#e7a93b;border-radius:4px"></div><div style="flex:8;background:#34c77b;border-radius:4px"></div><div style="flex:4;background:#e7a93b;border-radius:4px"></div><div style="flex:18;background:#34c77b;border-radius:4px"></div></div><div style="display:flex;justify-content:space-between;font-size:10px;color:#70808c;margin-top:5px"><span>08:00</span><span>Now</span></div></div>
<div style="background:white;border-radius:10px;padding:12px"><div style="font-size:11px;color:#70808c;margin-bottom:8px">DEPENDENCY DETAILS</div><div style="display:grid;grid-template-columns:1.5fr .8fr 1fr 2fr;gap:8px;font-size:12px;padding-bottom:8px;border-bottom:1px solid #edf0f1;font-weight:700"><div>CHECK</div><div>STATUS</div><div>DURATION</div><div>DETAIL</div></div><div style="display:grid;grid-template-columns:1.5fr .8fr 1fr 2fr;gap:8px;font-size:12px;padding-top:10px"><div>PrimaryDatabase</div><div style="color:#22a968">Healthy</div><div>4 ms</div><div>Connection successful</div></div><div style="display:grid;grid-template-columns:1.5fr .8fr 1fr 2fr;gap:8px;font-size:12px;padding-top:10px"><div>MessageBus</div><div style="color:#bb7a11;font-weight:700">Degraded</div><div>26 ms</div><div>Consumer lag above threshold</div></div></div>
</div>
</div>
</div>

<p class="subtitle">The key question is whether this overview/detail split gives you the right operational emphasis: current state first, then dependency evidence and history.</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1789733678007}
16 changes: 16 additions & 0 deletions HealthMonitoring.Client/HealthMonitoring.Client.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PackageId>HealthMonitoring.Client</PackageId>
<Description>Reusable client for registering services and dependency mappings with HealthMonitoring.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>
169 changes: 169 additions & 0 deletions HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;

namespace HealthMonitoring.Client;

public sealed class HealthMonitoringRegistrationClientOptions
{
public Uri MonitoringServerUrl { get; set; } = new("http://localhost:9620");
}

public sealed class ServiceRegistrationOptions
{
public string ServiceId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public Uri HealthUrl { get; init; } = new("http://localhost/health");
public string MonitorType { get; init; } = "HttpHealthEndpoint";
public string? ConnectionString { get; init; }
public bool IgnoreCertificateErrors { get; init; }
public TimeSpan PollingInterval { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromDays(365);
public string? Group { get; init; }
public string? Description { get; init; }
public string? Version { get; init; }
public string? Host { get; init; }
public object? StatusPolicy { get; init; }
}

public sealed record DependencyMappingOptions(string DependencyName, string TargetServiceId);

public sealed record ServiceRegistrationResult(string ServiceId, string Action, Guid MonitoredServiceId);

public interface IHealthMonitoringRegistrationClient
{
Task<ServiceRegistrationResult> RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default);
Task SetDependencyMappingsAsync(string serviceId, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default);
Task<ServiceRegistrationResult> RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default);
}

public sealed class HealthMonitoringRegistrationClient(HttpClient httpClient) : IHealthMonitoringRegistrationClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

public async Task<ServiceRegistrationResult> RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
ValidateServiceOptions(options);

using var response = await httpClient.PostAsJsonAsync("api/services/register", new ServiceRegistrationPayload(
options.ServiceId,
options.Name,
options.HealthUrl.ToString(),
ParseMonitorType(options.MonitorType),
options.ConnectionString,
options.IgnoreCertificateErrors,
ToSeconds(options.PollingInterval, nameof(options.PollingInterval)),
ToSeconds(options.RequestTimeout, nameof(options.RequestTimeout)),
ToDays(options.RetentionPeriod, nameof(options.RetentionPeriod)),
options.StatusPolicy,
options.Group,
options.Description,
options.Version,
options.Host), JsonOptions, cancellationToken);

await EnsureSuccessAsync(response, "register service", cancellationToken);
var result = await response.Content.ReadFromJsonAsync<ServiceRegistrationResponsePayload>(JsonOptions, cancellationToken)
?? throw new InvalidOperationException("The monitoring server returned an empty registration response.");
return new ServiceRegistrationResult(result.ServiceId, result.Action, result.Service.Id);
}

public async Task SetDependencyMappingsAsync(string serviceId, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(serviceId)) throw new ArgumentException("A service ID is required.", nameof(serviceId));
ArgumentNullException.ThrowIfNull(mappings);

var existing = await GetDependencyLinksAsync(serviceId, cancellationToken);
foreach (var mapping in mappings)
{
if (string.IsNullOrWhiteSpace(mapping.DependencyName)) throw new ArgumentException("A dependency name is required.", nameof(mappings));
if (string.IsNullOrWhiteSpace(mapping.TargetServiceId)) throw new ArgumentException("A target service ID is required.", nameof(mappings));

var target = await GetServiceAsync(mapping.TargetServiceId, cancellationToken);
var payload = new DependencyMappingPayload(mapping.DependencyName, target.Id);
var current = existing.FirstOrDefault(link => string.Equals(link.DependencyName.Trim(), mapping.DependencyName.Trim(), StringComparison.OrdinalIgnoreCase));
using var response = current is null
? await httpClient.PostAsJsonAsync($"api/services/{Encode(serviceId)}/dependency-links", payload, JsonOptions, cancellationToken)
: await httpClient.PutAsJsonAsync($"api/services/{Encode(serviceId)}/dependency-links/{current.Id}", payload, JsonOptions, cancellationToken);
await EnsureSuccessAsync(response, "save dependency mapping", cancellationToken);
}
}

public async Task<ServiceRegistrationResult> RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable<DependencyMappingOptions> mappings, CancellationToken cancellationToken = default)
{
var result = await RegisterAsync(options, cancellationToken);
await SetDependencyMappingsAsync(options.ServiceId, mappings, cancellationToken);
return result;
}

private async Task<IReadOnlyList<DependencyLinkResponsePayload>> GetDependencyLinksAsync(string serviceId, CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync($"api/services/{Encode(serviceId)}/dependency-links", cancellationToken);
await EnsureSuccessAsync(response, "read dependency mappings", cancellationToken);
return await response.Content.ReadFromJsonAsync<List<DependencyLinkResponsePayload>>(JsonOptions, cancellationToken) ?? [];
}

private async Task<ServiceIdentityPayload> GetServiceAsync(string serviceId, CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync($"api/services/{Encode(serviceId)}", cancellationToken);
await EnsureSuccessAsync(response, $"read target service '{serviceId}'", cancellationToken);
return await response.Content.ReadFromJsonAsync<ServiceIdentityPayload>(JsonOptions, cancellationToken)
?? throw new InvalidOperationException("The monitoring server returned an empty service response.");
}

private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"Could not {operation}. Server returned {(int)response.StatusCode}: {body}", null, response.StatusCode);
}

private static string Encode(string value) => Uri.EscapeDataString(value);

private static int ParseMonitorType(string monitorType) =>
string.Equals(monitorType, "KurrentDb", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(monitorType, "SqlServer", StringComparison.OrdinalIgnoreCase) ? 2 : 0;

private static int ToSeconds(TimeSpan value, string name)
{
if (value <= TimeSpan.Zero || value.TotalSeconds > int.MaxValue) throw new ArgumentOutOfRangeException(name);
return (int)value.TotalSeconds;
}

private static int ToDays(TimeSpan value, string name)
{
if (value <= TimeSpan.Zero || value.TotalDays > int.MaxValue) throw new ArgumentOutOfRangeException(name);
return (int)value.TotalDays;
}

private static void ValidateServiceOptions(ServiceRegistrationOptions options)
{
if (string.IsNullOrWhiteSpace(options.ServiceId)) throw new ArgumentException("A service ID is required.", nameof(options));
if (string.IsNullOrWhiteSpace(options.Name)) throw new ArgumentException("A service name is required.", nameof(options));
if (!options.HealthUrl.IsAbsoluteUri) throw new ArgumentException("The health URL must be absolute.", nameof(options));
}

private sealed record ServiceRegistrationPayload(string ServiceId, string Name, string HealthUrl, int MonitorType, string? ConnectionString, bool IgnoreCertificateErrors, int PollingIntervalSeconds, int RequestTimeoutSeconds, int RetentionDays, object? StatusPolicy, string? Group, string? Description, string? Version, string? Host);
private sealed record ServiceRegistrationResponsePayload(string ServiceId, string Action, ServiceIdentityPayload Service);
private sealed record ServiceIdentityPayload(Guid Id, string ServiceId, string Name);
private sealed record DependencyMappingPayload(string DependencyName, Guid TargetMonitoredServiceId);
private sealed record DependencyLinkResponsePayload(Guid Id, string DependencyName, Guid TargetMonitoredServiceId, string TargetServiceId, string TargetServiceName);
}

public static class HealthMonitoringRegistrationClientServiceCollectionExtensions
{
public static IHttpClientBuilder AddHealthMonitoringRegistrationClient(this IServiceCollection services, Action<HealthMonitoringRegistrationClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var options = new HealthMonitoringRegistrationClientOptions();
configure(options);
if (!options.MonitoringServerUrl.IsAbsoluteUri) throw new ArgumentException("The monitoring server URL must be absolute.", nameof(configure));

return services.AddHttpClient<IHealthMonitoringRegistrationClient, HealthMonitoringRegistrationClient>(client =>
{
client.BaseAddress = options.MonitoringServerUrl;
});
}
}
Loading
Loading