using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; using ScadaLink.HealthMonitoring; namespace ScadaLink.CentralUI.Services; /// /// Default implementation — a thin pass-through /// to . Default page size is 100 (the /// AuditResultsGrid default for #23 M7). /// /// /// /// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a /// fresh — and therefore a fresh /// ScadaLinkDbContext — rather than sharing the scoped Blazor-circuit /// context. Without this, the Audit Log page's query-string auto-load /// (/audit/log?correlationId=…) races AuditFilterBar.GetAllSitesAsync() /// on the single circuit-scoped ScadaLinkDbContext, producing EF Core's /// "A second operation was started on this context instance" error. Scope-per-query /// removes the shared state so the two initial loads can run concurrently. This /// mirrors the scope-per-message pattern used by AuditLogIngestActor. /// /// /// A second constructor takes an directly — a /// test seam (mirroring AuditLogIngestActor's dual ctor) so unit tests can /// substitute a stub without standing up a DI container. /// /// public sealed class AuditLogQueryService : IAuditLogQueryService { // M7 Bundle E (T13): trailing window for the Health dashboard's Audit KPI tiles. // Hard-coded here rather than configurable because the requirement // (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour" // and "% errors over the last hour" as the KPI definition. private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1); // 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 IAuditLogRepository? _injectedRepository; private readonly ICentralHealthAggregator _healthAggregator; /// /// Production constructor — resolves from a /// fresh DI scope on every call so each query gets its own /// ScadaLinkDbContext and never contends with the circuit-scoped /// context the filter bar uses. /// public AuditLogQueryService( IServiceScopeFactory scopeFactory, ICentralHealthAggregator healthAggregator) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); } /// /// Test-seam constructor — injects a repository instance whose lifetime the /// caller owns. Used by unit tests that substitute a stub repository. /// public AuditLogQueryService( IAuditLogRepository repository, ICentralHealthAggregator healthAggregator) { _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository)); _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); } public int DefaultPageSize => 100; public async Task> QueryAsync( AuditLogQueryFilter filter, AuditLogPaging? paging = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(filter); var effective = paging ?? new AuditLogPaging(DefaultPageSize); // Test-seam ctor: use the injected repository directly. if (_injectedRepository is not null) { return await _injectedRepository.QueryAsync(filter, effective, ct); } // Production: a fresh scope (and thus a fresh DbContext) per query so the // page's auto-load never shares the circuit-scoped context. await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); return await repository.QueryAsync(filter, effective, ct); } /// public async Task GetKpiSnapshotAsync(CancellationToken ct = default) { // 1. Volume + error counts: aggregate over the trailing 1h window. // BacklogTotal is left at 0 by the repository — we fill it from the // in-memory health aggregator below. Resolved via a per-call scope on // the production path for the same context-isolation reason as // QueryAsync; the test-seam ctor uses the injected repository. AuditLogKpiSnapshot repoSnapshot; if (_injectedRepository is not null) { repoSnapshot = await _injectedRepository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct); } else { await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); repoSnapshot = await repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct); } // 2. Backlog: sum PendingCount across every site's latest report. // Sites that have not yet reported or whose reporter is disabled // leave SiteAuditBacklog null — those contribute zero (a Missing // snapshot is "unknown", not "zero", but the tile is best-effort). long backlog = 0; foreach (var state in _healthAggregator.GetAllSiteStates().Values) { var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount; if (pending is > 0) { backlog += pending.Value; } } return repoSnapshot with { BacklogTotal = backlog }; } /// public async Task> GetExecutionTreeAsync( Guid executionId, CancellationToken ct = default) { // Test-seam ctor: use the injected repository directly. if (_injectedRepository is not null) { return await _injectedRepository.GetExecutionTreeAsync(executionId, ct); } // Production: a fresh scope (and thus a fresh DbContext) per call — the // same context-isolation contract QueryAsync upholds, so the tree // page's auto-load never shares the circuit-scoped context. await using var scope = _scopeFactory!.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); return await repository.GetExecutionTreeAsync(executionId, ct); } }