feat(health): 0.2.0 — optional per-entry data + Akka cluster-view
Phase 0 of docs/plans/2026-07-22-overview-dashboard-impl-plan.md: give the
canonical health JSON a structured channel so the family overview dashboard can
read each Akka cluster's current leader.
- ZbHealthWriter: optional `"data": {...}` per entry, sourced from
HealthReportEntry.Data, emitted only when non-empty. Per-property JsonIgnore
(NOT a global DefaultIgnoreCondition) so `"description": null` still renders —
payloads from data-less checks stay byte-identical to 0.1.0.
- AkkaClusterHealthCheck: BuildClusterData publishes this node's own view —
leader (omitted while unknown), selfAddress, selfRoles (sorted), memberCount,
unreachableCount — on every result path. The startup-safety paths (no
ActorSystem / cluster inaccessible) stay description-only.
- Tests: writer data emit/omit (raw-JSON assert on the omit case), and a real
single-node self-joined cluster via Akka.TestKit.Xunit2 for the data values.
70 tests green (25/39/6).
- Version 0.1.0 -> 0.2.0; 3 packages published to the Gitea feed and
restore-verified from a scratch consumer, which serves data.leader live.
This commit is contained in:
@@ -4,7 +4,7 @@ Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGate
|
|||||||
|
|
||||||
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
|
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
|
||||||
|
|
||||||
**Built at 0.1.0. NOT yet adopted by the three apps.** Adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
|
**Built at 0.2.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,14 +46,14 @@ dotnet test ZB.MOM.WW.Health.slnx
|
|||||||
dotnet pack ZB.MOM.WW.Health.slnx -c Release -o ./artifacts
|
dotnet pack ZB.MOM.WW.Health.slnx -c Release -o ./artifacts
|
||||||
```
|
```
|
||||||
|
|
||||||
All three test assemblies run offline:
|
All three test assemblies run offline (the cluster-data tests form a single-node in-process Akka cluster on a loopback port):
|
||||||
|
|
||||||
| Assembly | Tests |
|
| Assembly | Tests |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ZB.MOM.WW.Health.Tests` | 20 |
|
| `ZB.MOM.WW.Health.Tests` | 25 |
|
||||||
| `ZB.MOM.WW.Health.Akka.Tests` | 32 |
|
| `ZB.MOM.WW.Health.Akka.Tests` | 39 |
|
||||||
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
||||||
| **Total** | **58** |
|
| **Total** | **70** |
|
||||||
|
|
||||||
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
|
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ All three test assemblies run offline:
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Built at **0.1.0** and published to the Gitea NuGet feed. **Not yet adopted** by the three apps — adoption is tracked in the component backlog:
|
Built at **0.2.0** and published to the Gitea NuGet feed. **Adopted** by all four apps — adoption is tracked in the component backlog:
|
||||||
|
|
||||||
- `~/Desktop/scadaproj/components/health/GAPS.md` — adoption order, effort, and risk
|
- `~/Desktop/scadaproj/components/health/GAPS.md` — adoption order, effort, and risk
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.2.0</Version>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<!-- Emit XML docs so the public API summaries ship inside the packed nupkgs (IntelliSense for
|
<!-- Emit XML docs so the public API summaries ship inside the packed nupkgs (IntelliSense for
|
||||||
consumers). CS1591 (missing doc on a public member) is suppressed so undocumented test /
|
consumers). CS1591 (missing doc on a public member) is suppressed so undocumented test /
|
||||||
|
|||||||
@@ -30,7 +30,19 @@ MxAccessGateway consumes the core package only — it has no Akka cluster and no
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
All three packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps all three packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Health packages.
|
All three packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.2.0**. A single version bump in `Directory.Build.props` bumps all three packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Health packages.
|
||||||
|
|
||||||
|
### 0.2.0 — per-entry `data` (additive, non-breaking)
|
||||||
|
|
||||||
|
- `ZbHealthWriter` emits an optional `"data": { … }` object per entry, sourced from the check's
|
||||||
|
`HealthCheckResult.Data`, **only when non-empty** — a check that publishes no data produces a
|
||||||
|
byte-identical body to 0.1.0, so every existing consumer is unaffected. Data keys are written
|
||||||
|
verbatim (the camelCase policy applies to the envelope, not to dictionary keys).
|
||||||
|
- `AkkaClusterHealthCheck` populates that dictionary with this node's cluster view: `leader`
|
||||||
|
(omitted while unknown), `selfAddress`, `selfRoles`, `memberCount`, `unreachableCount`. The
|
||||||
|
startup-safety paths (no `ActorSystem`, cluster not yet accessible) stay description-only.
|
||||||
|
- Consumers gain the leader field on a package bump alone — no application code change, provided the
|
||||||
|
app registers the shared `AkkaClusterHealthCheck`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,14 +54,16 @@ dotnet build ZB.MOM.WW.Health.slnx
|
|||||||
dotnet test ZB.MOM.WW.Health.slnx
|
dotnet test ZB.MOM.WW.Health.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
All three test assemblies run with `dotnet test` and require no external dependencies (no running Akka cluster, no database):
|
All three test assemblies run with `dotnet test` and require no external dependencies — no database and no
|
||||||
|
external cluster (the cluster-data tests form a single-node in-process Akka cluster on a loopback port
|
||||||
|
via `Akka.TestKit.Xunit2`):
|
||||||
|
|
||||||
| Assembly | Tests |
|
| Assembly | Tests |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ZB.MOM.WW.Health.Tests` | 20 |
|
| `ZB.MOM.WW.Health.Tests` | 25 |
|
||||||
| `ZB.MOM.WW.Health.Akka.Tests` | 32 |
|
| `ZB.MOM.WW.Health.Akka.Tests` | 39 |
|
||||||
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
||||||
| **Total** | **58** |
|
| **Total** | **70** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,9 +76,9 @@ dotnet pack ZB.MOM.WW.Health.slnx -c Release -o ./artifacts
|
|||||||
Produces three `.nupkg` files in `artifacts/`:
|
Produces three `.nupkg` files in `artifacts/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
ZB.MOM.WW.Health.0.1.0.nupkg
|
ZB.MOM.WW.Health.0.2.0.nupkg
|
||||||
ZB.MOM.WW.Health.Akka.0.1.0.nupkg
|
ZB.MOM.WW.Health.Akka.0.2.0.nupkg
|
||||||
ZB.MOM.WW.Health.EntityFrameworkCore.0.1.0.nupkg
|
ZB.MOM.WW.Health.EntityFrameworkCore.0.2.0.nupkg
|
||||||
```
|
```
|
||||||
|
|
||||||
`GeneratePackageOnBuild` is off — pack explicitly as above.
|
`GeneratePackageOnBuild` is off — pack explicitly as above.
|
||||||
@@ -73,7 +87,7 @@ ZB.MOM.WW.Health.EntityFrameworkCore.0.1.0.nupkg
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Built at 0.1.0. NOT yet adopted by the three apps.** Adoption is tracked in the component backlog:
|
**Built at 0.2.0 and published to the Gitea NuGet feed. Adopted by all four apps** (OtOpcUa, MxAccessGateway, ScadaBridge, HistorianGateway). Adoption is tracked in the component backlog:
|
||||||
|
|
||||||
- `~/Desktop/scadaproj/components/health/GAPS.md`
|
- `~/Desktop/scadaproj/components/health/GAPS.md`
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
|||||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||||
|
|
||||||
MemberStatus status;
|
MemberStatus status;
|
||||||
|
IReadOnlyDictionary<string, object> data;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
||||||
@@ -51,7 +52,9 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
|||||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
|
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
|
||||||
// escaping exception (which the host would record as Unhealthy and pull the node from
|
// escaping exception (which the host would record as Unhealthy and pull the node from
|
||||||
// rotation).
|
// rotation).
|
||||||
status = Cluster.Get(system).SelfMember.Status;
|
var cluster = Cluster.Get(system);
|
||||||
|
status = cluster.SelfMember.Status;
|
||||||
|
data = BuildClusterData(cluster);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -62,10 +65,47 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
|||||||
var description = $"Akka cluster member status: {status}";
|
var description = $"Akka cluster member status: {status}";
|
||||||
var result = health switch
|
var result = health switch
|
||||||
{
|
{
|
||||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
HealthStatus.Healthy => HealthCheckResult.Healthy(description, data),
|
||||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description),
|
HealthStatus.Degraded => HealthCheckResult.Degraded(description, data: data),
|
||||||
_ => HealthCheckResult.Unhealthy(description),
|
_ => HealthCheckResult.Unhealthy(description, data: data),
|
||||||
};
|
};
|
||||||
return Task.FromResult(result);
|
return Task.FromResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects this node's view of the cluster into the check's <c>data</c> dictionary.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cluster">The cluster extension for the local <see cref="ActorSystem"/>.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// JSON-friendly values only (string / int / string[]), so the canonical health writer can emit
|
||||||
|
/// them verbatim: <c>leader</c> (omitted while the leader is unknown, e.g. pre-join or during
|
||||||
|
/// convergence), <c>selfAddress</c>, <c>selfRoles</c> (sorted for a stable payload),
|
||||||
|
/// <c>memberCount</c>, <c>unreachableCount</c>.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is deliberately each node's <em>own</em> view rather than an authoritative fleet answer —
|
||||||
|
/// a consumer that reads it from several members of the same cluster can compare them, and a
|
||||||
|
/// disagreement is a split-brain tell.
|
||||||
|
/// </remarks>
|
||||||
|
internal static IReadOnlyDictionary<string, object> BuildClusterData(Cluster cluster)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(cluster);
|
||||||
|
|
||||||
|
var state = cluster.State;
|
||||||
|
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["selfAddress"] = cluster.SelfAddress.ToString(),
|
||||||
|
["selfRoles"] = cluster.SelfMember.Roles.OrderBy(static r => r, StringComparer.Ordinal).ToArray(),
|
||||||
|
["memberCount"] = state.Members.Count,
|
||||||
|
["unreachableCount"] = state.Unreachable.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Omitted rather than emitted as null: a missing key reads as "this node does not know yet",
|
||||||
|
// which is exactly the pre-join / mid-convergence state.
|
||||||
|
var leader = state.Leader;
|
||||||
|
if (leader is not null)
|
||||||
|
data["leader"] = leader.ToString();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
@@ -16,15 +17,18 @@ namespace ZB.MOM.WW.Health;
|
|||||||
/// "status": "Healthy|Degraded|Unhealthy",
|
/// "status": "Healthy|Degraded|Unhealthy",
|
||||||
/// "totalDurationMs": 12.34,
|
/// "totalDurationMs": 12.34,
|
||||||
/// "entries": {
|
/// "entries": {
|
||||||
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23 }
|
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23, "data": { ... } }
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// </code>
|
/// </code>
|
||||||
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
|
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
|
||||||
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
|
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
|
||||||
/// shape. The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded
|
/// shape. The <c>data</c> key is the opposite: it is emitted only when the check supplied a non-empty
|
||||||
/// → 200, Unhealthy → 503); this writer only renders the body and sets
|
/// <see cref="HealthCheckResult.Data"/> dictionary, so payloads from checks that publish no data stay
|
||||||
/// <c>Content-Type: application/json</c>.
|
/// byte-identical to the pre-0.2.0 shape. Its keys are written verbatim (the camelCase naming policy
|
||||||
|
/// applies to the envelope properties, not to dictionary keys). The HTTP status code is left to the
|
||||||
|
/// ASP.NET Core health-checks middleware (Healthy/Degraded → 200, Unhealthy → 503); this writer only
|
||||||
|
/// renders the body and sets <c>Content-Type: application/json</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class ZbHealthWriter
|
public static class ZbHealthWriter
|
||||||
{
|
{
|
||||||
@@ -60,6 +64,10 @@ public static class ZbHealthWriter
|
|||||||
Status = e.Value.Status.ToString(),
|
Status = e.Value.Status.ToString(),
|
||||||
Description = e.Value.Description,
|
Description = e.Value.Description,
|
||||||
DurationMs = e.Value.Duration.TotalMilliseconds,
|
DurationMs = e.Value.Duration.TotalMilliseconds,
|
||||||
|
// HealthReportEntry.Data is never null (empty when the check set none); mapping
|
||||||
|
// empty → null is what drops the key entirely, keeping data-less payloads
|
||||||
|
// byte-identical to the pre-0.2.0 shape.
|
||||||
|
Data = e.Value.Data.Count > 0 ? e.Value.Data : null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,5 +86,11 @@ public static class ZbHealthWriter
|
|||||||
public string Status { get; init; } = string.Empty;
|
public string Status { get; init; } = string.Empty;
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
public double DurationMs { get; init; }
|
public double DurationMs { get; init; }
|
||||||
|
|
||||||
|
// Per-property ignore, deliberately NOT a global DefaultIgnoreCondition: the writer must keep
|
||||||
|
// emitting "description": null (see SerializerOptions above) while omitting "data" entirely
|
||||||
|
// when a check publishes none.
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public IReadOnlyDictionary<string, object>? Data { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.Cluster;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using ZB.MOM.WW.Health.Akka;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the cluster-view <c>data</c> dictionary that <see cref="AkkaClusterHealthCheck"/> publishes
|
||||||
|
/// from 0.2.0 on (consumed by the family overview dashboard to show each cluster's current leader).
|
||||||
|
/// Uses a real single-node self-joined cluster via <see cref="TestKit"/> — the values come off
|
||||||
|
/// <see cref="Cluster"/>, which cannot be faked, so a formed cluster is the only honest fixture.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AkkaClusterDataTests : TestKit
|
||||||
|
{
|
||||||
|
private const string ClusterConfig = """
|
||||||
|
akka {
|
||||||
|
actor.provider = cluster
|
||||||
|
remote.dot-netty.tcp {
|
||||||
|
hostname = "127.0.0.1"
|
||||||
|
port = 0
|
||||||
|
}
|
||||||
|
cluster {
|
||||||
|
roles = ["driver", "admin"]
|
||||||
|
# Keep the single-node self-join deterministic and fast in-test.
|
||||||
|
min-nr-of-members = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static readonly TimeSpan FormationTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public AkkaClusterDataTests()
|
||||||
|
: base(ClusterConfig, nameof(AkkaClusterDataTests))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Self-joins the test node and waits until it is Up with a converged leader.</summary>
|
||||||
|
private Cluster FormSingleNodeCluster()
|
||||||
|
{
|
||||||
|
var cluster = Cluster.Get(Sys);
|
||||||
|
cluster.Join(cluster.SelfAddress);
|
||||||
|
AwaitCondition(
|
||||||
|
() => cluster.SelfMember.Status == MemberStatus.Up && cluster.State.Leader is not null,
|
||||||
|
FormationTimeout);
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildClusterData_SingleNodeCluster_ReportsSelfAsLeader()
|
||||||
|
{
|
||||||
|
var cluster = FormSingleNodeCluster();
|
||||||
|
|
||||||
|
var data = AkkaClusterHealthCheck.BuildClusterData(cluster);
|
||||||
|
|
||||||
|
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["leader"]));
|
||||||
|
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["selfAddress"]));
|
||||||
|
Assert.Equal(1, Assert.IsType<int>(data["memberCount"]));
|
||||||
|
Assert.Equal(0, Assert.IsType<int>(data["unreachableCount"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildClusterData_RolesRoundTripSorted()
|
||||||
|
{
|
||||||
|
var cluster = FormSingleNodeCluster();
|
||||||
|
|
||||||
|
var roles = Assert.IsType<string[]>(AkkaClusterHealthCheck.BuildClusterData(cluster)["selfRoles"]);
|
||||||
|
|
||||||
|
// Sorted, so the payload is stable across restarts; the configured roles survive verbatim.
|
||||||
|
Assert.Equal(new[] { "admin", "driver" }, roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckHealth_FormedCluster_HealthyResultCarriesClusterData()
|
||||||
|
{
|
||||||
|
var cluster = FormSingleNodeCluster();
|
||||||
|
var provider = new ServiceCollection().AddSingleton(Sys).BuildServiceProvider();
|
||||||
|
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||||
|
|
||||||
|
var result = await check.CheckHealthAsync(NewContext(check));
|
||||||
|
|
||||||
|
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||||
|
Assert.Equal(cluster.SelfAddress.ToString(), result.Data["leader"]);
|
||||||
|
Assert.Equal(1, result.Data["memberCount"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckHealth_NoActorSystem_CarriesNoData()
|
||||||
|
{
|
||||||
|
// The startup-safety paths stay description-only: an empty data dictionary is what makes the
|
||||||
|
// writer omit the "data" key entirely, so a not-yet-started node's payload is unchanged.
|
||||||
|
var provider = new ServiceCollection().BuildServiceProvider();
|
||||||
|
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||||
|
|
||||||
|
var result = await check.CheckHealthAsync(NewContext(check));
|
||||||
|
|
||||||
|
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||||
|
Assert.Empty(result.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||||
|
{
|
||||||
|
Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -88,6 +88,8 @@ public sealed class AkkaClusterStatusPolicyTests
|
|||||||
var result = await check.CheckHealthAsync(NewContext(check));
|
var result = await check.CheckHealthAsync(NewContext(check));
|
||||||
|
|
||||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||||
|
// No cluster to describe → no data, so the emitted payload stays the pre-0.2.0 shape.
|
||||||
|
Assert.Empty(result.Data);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ public sealed class ResponseWriterTests
|
|||||||
{
|
{
|
||||||
private readonly HealthCheckResult _result;
|
private readonly HealthCheckResult _result;
|
||||||
|
|
||||||
public StubHealthCheck(HealthStatus status, string? description = null) =>
|
public StubHealthCheck(
|
||||||
_result = new HealthCheckResult(status, description);
|
HealthStatus status,
|
||||||
|
string? description = null,
|
||||||
|
IReadOnlyDictionary<string, object>? data = null) =>
|
||||||
|
_result = new HealthCheckResult(status, description, exception: null, data: data);
|
||||||
|
|
||||||
public Task<HealthCheckResult> CheckHealthAsync(
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
HealthCheckContext context,
|
HealthCheckContext context,
|
||||||
@@ -31,12 +34,14 @@ public sealed class ResponseWriterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<HttpResponseMessage> GetReadyAsync(
|
private static async Task<HttpResponseMessage> GetReadyAsync(
|
||||||
HealthStatus status, string? description = "db reachable")
|
HealthStatus status,
|
||||||
|
string? description = "db reachable",
|
||||||
|
IReadOnlyDictionary<string, object>? data = null)
|
||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
builder.WebHost.UseTestServer();
|
builder.WebHost.UseTestServer();
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddCheck("db", new StubHealthCheck(status, description), tags: new[] { ZbHealthTags.Ready });
|
.AddCheck("db", new StubHealthCheck(status, description, data), tags: new[] { ZbHealthTags.Ready });
|
||||||
|
|
||||||
await using var app = builder.Build();
|
await using var app = builder.Build();
|
||||||
app.MapZbHealth();
|
app.MapZbHealth();
|
||||||
@@ -84,6 +89,51 @@ public sealed class ResponseWriterTests
|
|||||||
Assert.Equal(JsonValueKind.Null, description.ValueKind);
|
Assert.Equal(JsonValueKind.Null, description.ValueKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadyEndpoint_EntryWithData_EmitsDataObject_WithVerbatimKeys()
|
||||||
|
{
|
||||||
|
// The envelope properties are camelCased by the naming policy, but a check's own data keys are
|
||||||
|
// dictionary keys and must survive verbatim — the dashboard reads them by exact name
|
||||||
|
// (entries["akka-cluster"].data.leader), so a naming policy applied here would silently break
|
||||||
|
// the contract. Values may be any JSON-friendly type (string / int / string[]).
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["leader"] = "akka.tcp://sys@node-1:4053",
|
||||||
|
["memberCount"] = 2,
|
||||||
|
["selfRoles"] = new[] { "driver", "site-a" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await GetReadyAsync(HealthStatus.Healthy, data: data);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
var entryData = doc.RootElement.GetProperty("entries").GetProperty("db").GetProperty("data");
|
||||||
|
|
||||||
|
Assert.Equal("akka.tcp://sys@node-1:4053", entryData.GetProperty("leader").GetString());
|
||||||
|
Assert.Equal(2, entryData.GetProperty("memberCount").GetInt32());
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "driver", "site-a" },
|
||||||
|
entryData.GetProperty("selfRoles").EnumerateArray().Select(static e => e.GetString()).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadyEndpoint_EntryWithoutData_OmitsDataKeyEntirely()
|
||||||
|
{
|
||||||
|
// The 0.2.0 non-breaking invariant: a check that publishes no data must produce exactly the
|
||||||
|
// pre-0.2.0 body. Asserted on the raw JSON string so a `"data": null` regression (which
|
||||||
|
// TryGetProperty would still accept as "present") fails here.
|
||||||
|
var response = await GetReadyAsync(HealthStatus.Healthy);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.DoesNotContain("\"data\"", body, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var db = doc.RootElement.GetProperty("entries").GetProperty("db");
|
||||||
|
Assert.False(db.TryGetProperty("data", out _), "data key must be absent when the check set none");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
|
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"plan": "docs/plans/2026-07-22-overview-dashboard-impl-plan.md",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "1", "subject": "0.1 Health writer: optional per-entry `data` object", "status": "pending", "blockedBy": [] },
|
||||||
|
{ "id": "2", "subject": "0.2 AkkaClusterHealthCheck: populate cluster-view data", "status": "pending", "blockedBy": [] },
|
||||||
|
{ "id": "3", "subject": "0.3 Health 0.2.0: version, test, pack, publish, verify", "status": "pending", "blockedBy": ["1", "2"] },
|
||||||
|
{ "id": "4", "subject": "1.1 ScadaBridge: bump Health packages to 0.2.0", "status": "pending", "blockedBy": ["3"] },
|
||||||
|
{ "id": "5", "subject": "1.2 ScadaBridge: site health checks (akka-cluster, localdb, active-node)", "status": "pending", "blockedBy": ["4"] },
|
||||||
|
{ "id": "6", "subject": "1.3 ScadaBridge: map MapZbHealth on the site pipeline", "status": "pending", "blockedBy": ["5"] },
|
||||||
|
{ "id": "7", "subject": "1.4 ScadaBridge: site health tests", "status": "pending", "blockedBy": ["6"] },
|
||||||
|
{ "id": "8", "subject": "2.1 OtOpcUa: verify `akka` check is the shared AkkaClusterHealthCheck", "status": "pending", "blockedBy": [] },
|
||||||
|
{ "id": "9", "subject": "2.2 OtOpcUa: bump Health to 0.2.0", "status": "pending", "blockedBy": ["3", "8"] },
|
||||||
|
{ "id": "10", "subject": "2.3 Alignment bumps: mxgw + HistorianGateway Health 0.2.0", "status": "pending", "blockedBy": ["3"] },
|
||||||
|
{ "id": "11", "subject": "3.1 Overview: scaffold project", "status": "pending", "blockedBy": ["3"] },
|
||||||
|
{ "id": "12", "subject": "3.2 Overview: registry options + validation", "status": "pending", "blockedBy": ["11"] },
|
||||||
|
{ "id": "13", "subject": "3.3 Overview: health client + status model", "status": "pending", "blockedBy": ["11"] },
|
||||||
|
{ "id": "14", "subject": "3.4 Overview: poller + snapshot store", "status": "pending", "blockedBy": ["12", "13"] },
|
||||||
|
{ "id": "15", "subject": "3.5 Overview: leader aggregation", "status": "pending", "blockedBy": ["13"] },
|
||||||
|
{ "id": "16", "subject": "3.6 Overview: UI (ThemeShell, Overview page, InstanceCard)", "status": "pending", "blockedBy": ["14", "15"] },
|
||||||
|
{ "id": "17", "subject": "3.7 Overview: self-observability", "status": "pending", "blockedBy": ["14"] },
|
||||||
|
{ "id": "18", "subject": "3.8 Overview: tests", "status": "pending", "blockedBy": ["16", "17"] },
|
||||||
|
{ "id": "19", "subject": "3.9 Overview: config + docker", "status": "pending", "blockedBy": ["17"] },
|
||||||
|
{ "id": "20", "subject": "4 Live acceptance (design §9)", "status": "pending", "blockedBy": ["7", "9", "18", "19"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user