feat(kpi): K3 — KpiHistory project + options/validator + AddKpiHistory
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj" />
|
||||
@@ -53,5 +54,6 @@
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ZB.MOM.WW.ScadaBridge.Transport.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the KPI History (#26, M6) component. Bound from the
|
||||
/// <c>ScadaBridge:KpiHistory</c> section of <c>appsettings.json</c>. Defaults
|
||||
/// reflect the design: a 60-second sampling cadence for the point-in-time
|
||||
/// Notification Outbox / Site Call Audit KPI snapshots, a 90-day central
|
||||
/// retention window with a daily purge sweep, and a 200-point default ceiling
|
||||
/// on the series returned to the Central UI trend charts.
|
||||
/// </summary>
|
||||
public sealed class KpiHistoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// How often the recorder captures a KPI sample row (default 60 s). Must be
|
||||
/// strictly positive.
|
||||
/// </summary>
|
||||
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Central retention window in days for recorded KPI samples (default 90,
|
||||
/// range <c>[1, 3650]</c>). Rows older than this are dropped by the purge
|
||||
/// sweep.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// How often the purge sweep drops expired sample rows (default 1 day). Must
|
||||
/// be strictly positive.
|
||||
/// </summary>
|
||||
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// Default ceiling on the number of points returned in a single trend series
|
||||
/// query when the caller does not specify one (default 200, range
|
||||
/// <c>[2, 5000]</c>). At least two points are required to draw a line.
|
||||
/// </summary>
|
||||
public int DefaultMaxSeriesPoints { get; set; } = 200;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Validates <see cref="KpiHistoryOptions"/> on startup. A non-positive
|
||||
/// <see cref="KpiHistoryOptions.SampleInterval"/> or
|
||||
/// <see cref="KpiHistoryOptions.PurgeInterval"/> would stall the recorder /
|
||||
/// purge loops, so both are required to be strictly positive.
|
||||
/// <see cref="KpiHistoryOptions.RetentionDays"/> is bounded to <c>[1, 3650]</c>
|
||||
/// to keep the purge window sane (too short drops trends still being viewed;
|
||||
/// too long defeats the retention purpose). <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>
|
||||
/// is bounded to <c>[2, 5000]</c> — at least two points are needed to draw a
|
||||
/// line, and an unbounded ceiling would let a single trend query stream an
|
||||
/// arbitrarily large series to the Central UI.
|
||||
/// </summary>
|
||||
public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistoryOptions>
|
||||
{
|
||||
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.RetentionDays"/>.</summary>
|
||||
public const int MinRetentionDays = 1;
|
||||
|
||||
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.RetentionDays"/>.</summary>
|
||||
public const int MaxRetentionDays = 3650;
|
||||
|
||||
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>.</summary>
|
||||
public const int MinDefaultMaxSeriesPoints = 2;
|
||||
|
||||
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>.</summary>
|
||||
public const int MaxDefaultMaxSeriesPoints = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
|
||||
{
|
||||
builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
|
||||
"must be > 0; it is the recorder's sampling cadence.");
|
||||
|
||||
// Valid when RetentionDays is within [Min, Max] inclusive. The De Morgan'd
|
||||
// guard !(below Min OR above Max) is equivalent to (>= Min AND <= Max).
|
||||
builder.RequireThat(
|
||||
!(options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays),
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RetentionDays)} ({options.RetentionDays}) " +
|
||||
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||
|
||||
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.PurgeInterval)} ({options.PurgeInterval}) " +
|
||||
"must be > 0; it is the purge-sweep cadence.");
|
||||
|
||||
// Valid when DefaultMaxSeriesPoints is within [Min, Max] inclusive. The
|
||||
// De Morgan'd guard !(below Min OR above Max) is equivalent to
|
||||
// (>= Min AND <= Max).
|
||||
builder.RequireThat(
|
||||
!(options.DefaultMaxSeriesPoints < MinDefaultMaxSeriesPoints
|
||||
|| options.DefaultMaxSeriesPoints > MaxDefaultMaxSeriesPoints),
|
||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.DefaultMaxSeriesPoints)} ({options.DefaultMaxSeriesPoints}) " +
|
||||
$"must be in [{MinDefaultMaxSeriesPoints}, {MaxDefaultMaxSeriesPoints}].");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root for the KPI History (#26, M6) component.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// K3 scaffolds the component: it binds and validates
|
||||
/// <see cref="KpiHistoryOptions"/>. The recorder actor itself is created via
|
||||
/// <c>Props</c> in the Host on the active central node (K4/K5), not registered
|
||||
/// here — mirroring the Notification Outbox (#21) singleton wiring.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Configuration section bound to <see cref="KpiHistoryOptions"/>.</summary>
|
||||
public const string OptionsSection = "ScadaBridge:KpiHistory";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the KPI History (#26) component services: the validated
|
||||
/// <see cref="KpiHistoryOptions"/> binding. Idempotent re-registration of the
|
||||
/// validator is handled by the shared <c>AddValidatedOptions</c> helper
|
||||
/// (<c>TryAddEnumerable</c>); the options binding itself is call-once per
|
||||
/// <see cref="IServiceCollection"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
/// <param name="configuration">Application configuration used to bind <see cref="KpiHistoryOptions"/>.</param>
|
||||
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
|
||||
public static IServiceCollection AddKpiHistory(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Binds the "ScadaBridge:KpiHistory" section, registers the validator,
|
||||
// and enables ValidateOnStart in one call — same shape as AddAuditLog.
|
||||
services.AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(configuration, OptionsSection);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,152 @@
|
||||
using ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// K3 unit tests for <see cref="KpiHistoryOptionsValidator"/>: the default
|
||||
/// options must validate, and each rule (SampleInterval / RetentionDays /
|
||||
/// PurgeInterval / DefaultMaxSeriesPoints) must reject its out-of-range values.
|
||||
/// </summary>
|
||||
public class KpiHistoryOptionsValidatorTests
|
||||
{
|
||||
private static KpiHistoryOptionsValidator NewValidator() => new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_Defaults_Pass()
|
||||
{
|
||||
var result = NewValidator().Validate(null, new KpiHistoryOptions());
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SampleInterval > TimeSpan.Zero
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Validate_SampleInterval_Zero_Fails()
|
||||
{
|
||||
var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.Zero };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SampleInterval_Negative_Fails()
|
||||
{
|
||||
var opts = new KpiHistoryOptions { SampleInterval = TimeSpan.FromSeconds(-1) };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.SampleInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// RetentionDays in [1, 3650]
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)] // min
|
||||
[InlineData(90)] // default
|
||||
[InlineData(3650)] // max
|
||||
public void Validate_RetentionDays_InRange_Passes(int value)
|
||||
{
|
||||
var opts = new KpiHistoryOptions { RetentionDays = value };
|
||||
Assert.True(NewValidator().Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(3651)]
|
||||
[InlineData(-1)]
|
||||
public void Validate_RetentionDays_OutOfRange_Fails(int value)
|
||||
{
|
||||
var opts = new KpiHistoryOptions { RetentionDays = value };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PurgeInterval > TimeSpan.Zero
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Validate_PurgeInterval_Zero_Fails()
|
||||
{
|
||||
var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.Zero };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PurgeInterval_Negative_Fails()
|
||||
{
|
||||
var opts = new KpiHistoryOptions { PurgeInterval = TimeSpan.FromHours(-1) };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.PurgeInterval), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// DefaultMaxSeriesPoints in [2, 5000]
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)] // min
|
||||
[InlineData(200)] // default
|
||||
[InlineData(5000)] // max
|
||||
public void Validate_DefaultMaxSeriesPoints_InRange_Passes(int value)
|
||||
{
|
||||
var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
|
||||
Assert.True(NewValidator().Validate(null, opts).Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(5001)]
|
||||
[InlineData(0)]
|
||||
public void Validate_DefaultMaxSeriesPoints_OutOfRange_Fails(int value)
|
||||
{
|
||||
var opts = new KpiHistoryOptions { DefaultMaxSeriesPoints = value };
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains(nameof(KpiHistoryOptions.DefaultMaxSeriesPoints), StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllRulesViolated_AccumulatesEveryFailure()
|
||||
{
|
||||
// The base class aggregates ALL failures, not just the first — one
|
||||
// message per violated rule.
|
||||
var opts = new KpiHistoryOptions
|
||||
{
|
||||
SampleInterval = TimeSpan.Zero,
|
||||
RetentionDays = 0,
|
||||
PurgeInterval = TimeSpan.Zero,
|
||||
DefaultMaxSeriesPoints = 1,
|
||||
};
|
||||
var result = NewValidator().Validate(null, opts);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(4, result.Failures!.Count());
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user