feat(kpi): K11 — KpiHistoryQueryService (scoped read + bucketing)
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI;
|
||||
|
||||
@@ -51,6 +53,19 @@ public static class ServiceCollectionExtensions
|
||||
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
|
||||
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
|
||||
|
||||
// KPI History (M6, K11): CentralUI facade over IKpiHistoryRepository that
|
||||
// fetches a raw series and reduces it with KpiSeriesBucketer for the trend chart.
|
||||
//
|
||||
// Registered with an explicit factory so the IServiceScopeFactory ctor is
|
||||
// always chosen — KpiHistoryQueryService has a second (test-seam) ctor that
|
||||
// takes IKpiHistoryRepository directly, and both are constructor-resolvable,
|
||||
// so default activation would be ambiguous. The scope-factory ctor opens a
|
||||
// fresh DbContext per query, mirroring AuditLogQueryService so a chart's
|
||||
// auto-load never races other reads on the shared circuit-scoped context.
|
||||
services.AddScoped<IKpiHistoryQueryService>(sp => new KpiHistoryQueryService(
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<IOptions<KpiHistoryOptions>>()));
|
||||
|
||||
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync
|
||||
// that enforces the CentralUI-side Design-role trust boundary and translates
|
||||
// transport failures into typed BrowseFailure results for the dialog.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IKpiHistoryRepository"/>
|
||||
/// (M6 "KPI History & Trends", K11). The reusable trend chart talks to this
|
||||
/// service rather than the repository directly so tests can substitute a fake
|
||||
/// without spinning up EF Core, and so the bucketing / downsampling step lives in
|
||||
/// one place rather than being re-implemented per page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The query path fetches the raw series for one (source, metric, scope, scopeKey)
|
||||
/// tuple over <c>[fromUtc, toUtc]</c> and reduces it with
|
||||
/// <see cref="KpiSeriesBucketer"/> to at most <c>maxPoints</c> points. Mirroring
|
||||
/// <see cref="IAuditLogQueryService"/>, the production implementation opens its own
|
||||
/// DI scope per call so a chart's auto-load never contends with the circuit-scoped
|
||||
/// <c>ScadaBridgeDbContext</c>; a second (test-seam) constructor injects the
|
||||
/// repository directly.
|
||||
/// </remarks>
|
||||
public interface IKpiHistoryQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the bucketed series for one
|
||||
/// (<paramref name="source"/>, <paramref name="metric"/>, <paramref name="scope"/>,
|
||||
/// <paramref name="scopeKey"/>) tuple over <c>[<paramref name="fromUtc"/>,
|
||||
/// <paramref name="toUtc"/>]</c>, reduced to at most
|
||||
/// <paramref name="maxPoints"/> points (defaulting to
|
||||
/// <c>KpiHistoryOptions.DefaultMaxSeriesPoints</c> when null).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window must be non-degenerate (<paramref name="toUtc"/> strictly after
|
||||
/// <paramref name="fromUtc"/>) and the effective max must be at least 2 — the
|
||||
/// page layer guarantees both; this service passes them straight through to
|
||||
/// <see cref="KpiSeriesBucketer.Bucket"/>, which throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> on violation.
|
||||
/// </remarks>
|
||||
/// <param name="source">Source identifier — a value from <c>KpiSources</c>.</param>
|
||||
/// <param name="metric">Metric name from the source's catalog.</param>
|
||||
/// <param name="scope">Scope discriminator — a value from <c>KpiScopes</c>.</param>
|
||||
/// <param name="scopeKey">Scope qualifier (site id / node name); <c>null</c> for the Global scope.</param>
|
||||
/// <param name="fromUtc">Inclusive lower bound of the time window (UTC).</param>
|
||||
/// <param name="toUtc">Inclusive upper bound of the time window (UTC); must be after <paramref name="fromUtc"/>.</param>
|
||||
/// <param name="maxPoints">Optional ceiling on returned points; defaults to the configured default when null.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task resolving to the bucketed series in ascending bucket-start order.</returns>
|
||||
Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
|
||||
string source, string metric, string scope, string? scopeKey,
|
||||
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
using ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IKpiHistoryQueryService"/> implementation (M6 K11) — fetches
|
||||
/// the raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/> and
|
||||
/// reduces it with <see cref="KpiSeriesBucketer.Bucket"/> to at most the requested
|
||||
/// (or configured-default) number of points.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors <see cref="AuditLogQueryService"/>: the production constructor takes an
|
||||
/// <see cref="IServiceScopeFactory"/> and opens a fresh DI scope per query —
|
||||
/// resolving a fresh <see cref="IKpiHistoryRepository"/> (and therefore a fresh
|
||||
/// <c>ScadaBridgeDbContext</c>) — so a trend chart's auto-load never contends with
|
||||
/// the circuit-scoped context the rest of the page uses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second constructor injects an <see cref="IKpiHistoryRepository"/> directly — a
|
||||
/// test seam (same dual-ctor pattern as <see cref="AuditLogQueryService"/>) so unit
|
||||
/// tests can substitute a stub without standing up a DI container. Both ctors take
|
||||
/// <see cref="IOptions{KpiHistoryOptions}"/> for the default series-point ceiling.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class KpiHistoryQueryService : IKpiHistoryQueryService
|
||||
{
|
||||
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
|
||||
// Test seam: a directly-injected repository whose lifetime the test owns.
|
||||
// Null in the production ctor.
|
||||
private readonly IKpiHistoryRepository? _injectedRepository;
|
||||
|
||||
private readonly KpiHistoryOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="IKpiHistoryRepository"/> from a
|
||||
/// fresh DI scope on every call so each query gets its own
|
||||
/// <c>ScadaBridgeDbContext</c> and never contends with the circuit-scoped context.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
|
||||
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
|
||||
public KpiHistoryQueryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<KpiHistoryOptions> options)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-seam constructor — injects a repository instance whose lifetime the
|
||||
/// caller owns. Used by unit tests that substitute a stub repository.
|
||||
/// </summary>
|
||||
/// <param name="repository">The KPI history repository instance to use directly.</param>
|
||||
/// <param name="options">KPI History options supplying the default series-point ceiling.</param>
|
||||
public KpiHistoryQueryService(
|
||||
IKpiHistoryRepository repository,
|
||||
IOptions<KpiHistoryOptions> options)
|
||||
{
|
||||
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KpiSeriesPoint>> GetSeriesAsync(
|
||||
string source, string metric, string scope, string? scopeKey,
|
||||
DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints;
|
||||
|
||||
// Test-seam ctor: use the injected repository directly.
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
var injectedRaw = await _injectedRepository.GetRawSeriesAsync(
|
||||
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(injectedRaw, fromUtc, toUtc, effectiveMax);
|
||||
}
|
||||
|
||||
// Production: a fresh scope (and thus a fresh DbContext) per query so a
|
||||
// chart's auto-load never shares the circuit-scoped context.
|
||||
await using var serviceScope = _scopeFactory!.CreateAsyncScope();
|
||||
var repository = serviceScope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
|
||||
var raw = await repository.GetRawSeriesAsync(
|
||||
source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken);
|
||||
return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, effectiveMax);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
using ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service-level tests for <see cref="KpiHistoryQueryService"/> (M6 K11). The
|
||||
/// service fetches a raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/>
|
||||
/// and reduces it with <see cref="KpiSeriesBucketer"/>; these tests pin the
|
||||
/// argument-forwarding contract, the bucketer pass-through, and the
|
||||
/// <c>maxPoints ?? DefaultMaxSeriesPoints</c> fallback.
|
||||
/// </summary>
|
||||
public class KpiHistoryQueryServiceTests
|
||||
{
|
||||
private static readonly DateTime From = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime To = new(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static IOptions<KpiHistoryOptions> Options(int defaultMax) =>
|
||||
Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax });
|
||||
|
||||
private static IReadOnlyList<KpiSeriesPoint> Series(params (int minute, double value)[] pts) =>
|
||||
pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository()
|
||||
{
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
var raw = Series((0, 1d), (30, 2d));
|
||||
repo.GetRawSeriesAsync(
|
||||
"notification-outbox", "queue_depth", "site", "plant-a",
|
||||
From, To, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(raw));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
await sut.GetSeriesAsync(
|
||||
"notification-outbox", "queue_depth", "site", "plant-a", From, To);
|
||||
|
||||
await repo.Received(1).GetRawSeriesAsync(
|
||||
"notification-outbox", "queue_depth", "site", "plant-a",
|
||||
From, To, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope()
|
||||
{
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(Series()));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
await sut.GetSeriesAsync(
|
||||
"site-call-audit", "parked_count", "global", scopeKey: null, From, To);
|
||||
|
||||
await repo.Received(1).GetRawSeriesAsync(
|
||||
"site-call-audit", "parked_count", "global",
|
||||
Arg.Is<string?>(k => k == null),
|
||||
From, To, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax()
|
||||
{
|
||||
// raw.Count (3) <= max (200) → bucketer returns the same reference.
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
var raw = Series((0, 5d), (20, 6d), (40, 7d));
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(raw));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
|
||||
|
||||
// Bucketer returns the raw reference unchanged when raw.Count <= maxPoints.
|
||||
Assert.Same(raw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions()
|
||||
{
|
||||
// raw has 4 points. With DefaultMaxSeriesPoints=2 the bucketer downsamples
|
||||
// (4 > 2), so the result is NOT the raw reference and has at most 2 points.
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(raw));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
|
||||
|
||||
var result = await sut.GetSeriesAsync(
|
||||
"src", "metric", "global", null, From, To, maxPoints: null);
|
||||
|
||||
Assert.NotSame(raw, result);
|
||||
Assert.True(result.Count <= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault()
|
||||
{
|
||||
// raw has 4 points; explicit maxPoints=10 (>= raw.Count) wins over the
|
||||
// option's tiny default of 2, so the bucketer returns the raw reference.
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d));
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(raw));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2));
|
||||
|
||||
var result = await sut.GetSeriesAsync(
|
||||
"src", "metric", "global", null, From, To, maxPoints: 10);
|
||||
|
||||
// Explicit max (10) >= raw.Count (4) → raw returned unchanged, proving the
|
||||
// explicit value beat the default of 2 (which would have downsampled).
|
||||
Assert.Same(raw, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty()
|
||||
{
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
||||
|
||||
var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200));
|
||||
|
||||
var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor()
|
||||
{
|
||||
// The production (IServiceScopeFactory) ctor must resolve a fresh repository
|
||||
// per call — same scope-per-query contract AuditLogQueryService upholds, so a
|
||||
// chart's auto-load never shares the circuit-scoped DbContext.
|
||||
var resolvedRepos = new List<IKpiHistoryRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IKpiHistoryRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IKpiHistoryRepository>();
|
||||
repo.GetRawSeriesAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
|
||||
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new KpiHistoryQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
Options(defaultMax: 200));
|
||||
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
|
||||
await sut.GetSeriesAsync("src", "metric", "global", null, From, To);
|
||||
|
||||
// Each call opened its own scope → two distinct repo instances.
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user