refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming CSV exporter for the Audit Log page (#23 M7-T14 / Bundle F).
|
||||
///
|
||||
/// <para>
|
||||
/// The exporter iterates <see cref="IAuditLogRepository.QueryAsync"/> page by page
|
||||
/// using its keyset cursor and writes each row to a destination
|
||||
/// <see cref="Stream"/> as RFC 4180-compliant CSV. The output is flushed after
|
||||
/// each page so a large export starts streaming to the client immediately
|
||||
/// instead of buffering the whole result set in memory.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Output is capped at a caller-supplied <c>maxRows</c> ceiling; when the cap
|
||||
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
|
||||
/// exports.</c> footer line so an operator can tell a truncated download from
|
||||
/// a complete one. The header row contains the 21 columns of
|
||||
/// <see cref="AuditEvent"/> in declaration order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAuditLogExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Streams a CSV export of the rows matching <paramref name="filter"/> to
|
||||
/// <paramref name="output"/>, capping at <paramref name="maxRows"/>.
|
||||
/// </summary>
|
||||
/// <param name="filter">Repository filter to apply.</param>
|
||||
/// <param name="maxRows">
|
||||
/// Maximum number of data rows (excluding header / footer) to emit. The
|
||||
/// service stops paging once this is reached and appends a cap footer.
|
||||
/// </param>
|
||||
/// <param name="output">Destination stream — typically the HTTP response body.</param>
|
||||
/// <param name="ct">Cancellation token (e.g. <c>HttpContext.RequestAborted</c>).</param>
|
||||
/// <param name="pageSize">
|
||||
/// Optional override for the repository page size. Defaults to 1000 — large
|
||||
/// enough to amortise the per-query overhead, small enough that one page in
|
||||
/// memory is bounded.
|
||||
/// </param>
|
||||
Task ExportAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
int maxRows,
|
||||
Stream output,
|
||||
CancellationToken ct,
|
||||
int pageSize = AuditLogExportService.DefaultPageSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IAuditLogExportService"/>
|
||||
public sealed class AuditLogExportService : IAuditLogExportService
|
||||
{
|
||||
/// <summary>Default rows pulled per repository round-trip.</summary>
|
||||
public const int DefaultPageSize = 1000;
|
||||
|
||||
private readonly IAuditLogRepository _repository;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="AuditLogExportService"/>.
|
||||
/// </summary>
|
||||
/// <param name="repository">Audit log repository used to page through entries for export.</param>
|
||||
public AuditLogExportService(IAuditLogRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExportAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
int maxRows,
|
||||
Stream output,
|
||||
CancellationToken ct,
|
||||
int pageSize = DefaultPageSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
if (maxRows <= 0) throw new ArgumentOutOfRangeException(nameof(maxRows), "maxRows must be positive.");
|
||||
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be positive.");
|
||||
|
||||
// UTF-8 no-BOM: Excel will still recognise the CSV but the file stays
|
||||
// a clean ASCII-superset for downstream pipes / grep. The StreamWriter
|
||||
// leaves the underlying stream open so the controller can decide when
|
||||
// to dispose / complete it.
|
||||
await using var writer = new StreamWriter(
|
||||
output,
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
bufferSize: 4096,
|
||||
leaveOpen: true);
|
||||
writer.NewLine = "\r\n"; // RFC 4180
|
||||
|
||||
// Header — 21 columns in AuditEvent declaration order.
|
||||
await writer.WriteLineAsync(Header);
|
||||
|
||||
int written = 0;
|
||||
AuditLogPaging cursor = new(PageSize: Math.Min(pageSize, maxRows));
|
||||
|
||||
while (written < maxRows)
|
||||
{
|
||||
// Honour cancellation BEFORE we kick off another round-trip — this
|
||||
// is the deterministic cancellation point that the test pins on.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Tighten the last page's size so we never pull more than the cap.
|
||||
var remaining = maxRows - written;
|
||||
var effectivePageSize = Math.Min(cursor.PageSize, remaining);
|
||||
var pageCursor = cursor with { PageSize = effectivePageSize };
|
||||
|
||||
var page = await _repository.QueryAsync(filter, pageCursor, ct);
|
||||
if (page.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var evt in page)
|
||||
{
|
||||
if (written >= maxRows)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await writer.WriteLineAsync(FormatCsvRow(evt));
|
||||
written++;
|
||||
}
|
||||
|
||||
// Push bytes through the StreamWriter buffer into the underlying
|
||||
// stream so the client sees progress per-page instead of waiting
|
||||
// for the full export to buffer up.
|
||||
await writer.FlushAsync(ct);
|
||||
await output.FlushAsync(ct);
|
||||
|
||||
// Last page (short read) — no more data to fetch.
|
||||
if (page.Count < effectivePageSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var last = page[^1];
|
||||
cursor = new AuditLogPaging(
|
||||
PageSize: pageSize,
|
||||
AfterOccurredAtUtc: last.OccurredAtUtc,
|
||||
AfterEventId: last.EventId);
|
||||
}
|
||||
|
||||
if (written >= maxRows)
|
||||
{
|
||||
// Cap footer — visible to operators so a truncated download is
|
||||
// distinguishable from a complete one. The "#" prefix keeps it
|
||||
// out of the data columns; spreadsheet tools will surface it as
|
||||
// a single-cell row.
|
||||
await writer.WriteLineAsync(
|
||||
$"# Capped at {maxRows.ToString(CultureInfo.InvariantCulture)} rows. Use the CLI for larger exports.");
|
||||
await writer.FlushAsync(ct);
|
||||
await output.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// CSV helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The 21 column names in <see cref="AuditEvent"/> declaration order.</summary>
|
||||
internal const string Header =
|
||||
"EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
|
||||
"SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
|
||||
"HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
|
||||
"ResponseSummary,PayloadTruncated,Extra,ForwardState";
|
||||
|
||||
/// <summary>
|
||||
/// Serialises one <see cref="AuditEvent"/> as a CSV row (no trailing newline).
|
||||
/// Each nullable column renders as the empty string when null; non-null
|
||||
/// scalars use invariant culture so an export taken on one locale parses
|
||||
/// cleanly on another.
|
||||
/// </summary>
|
||||
/// <param name="evt">The audit event to format as a CSV row.</param>
|
||||
internal static string FormatCsvRow(AuditEvent evt)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
AppendField(sb, evt.EventId.ToString(), first: true);
|
||||
AppendField(sb, FormatDate(evt.OccurredAtUtc));
|
||||
AppendField(sb, FormatDate(evt.IngestedAtUtc));
|
||||
AppendField(sb, evt.Channel.ToString());
|
||||
AppendField(sb, evt.Kind.ToString());
|
||||
AppendField(sb, evt.CorrelationId?.ToString());
|
||||
AppendField(sb, evt.SourceSiteId);
|
||||
AppendField(sb, evt.SourceInstanceId);
|
||||
AppendField(sb, evt.SourceScript);
|
||||
AppendField(sb, evt.Actor);
|
||||
AppendField(sb, evt.Target);
|
||||
AppendField(sb, evt.Status.ToString());
|
||||
AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
|
||||
AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
|
||||
AppendField(sb, evt.ErrorMessage);
|
||||
AppendField(sb, evt.ErrorDetail);
|
||||
AppendField(sb, evt.RequestSummary);
|
||||
AppendField(sb, evt.ResponseSummary);
|
||||
AppendField(sb, evt.PayloadTruncated.ToString());
|
||||
AppendField(sb, evt.Extra);
|
||||
AppendField(sb, evt.ForwardState?.ToString());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? FormatDate(DateTime? value) =>
|
||||
value?.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string FormatDate(DateTime value) =>
|
||||
value.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void AppendField(StringBuilder sb, string? value, bool first = false)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
|
||||
bool needsQuoting = value.IndexOfAny(s_quoteTriggers) >= 0;
|
||||
if (!needsQuoting)
|
||||
{
|
||||
sb.Append(value);
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
sb.Append('"').Append('"');
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAuditLogQueryService"/> implementation — a thin pass-through
|
||||
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
|
||||
/// AuditResultsGrid default for #23 M7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// #23 M7 (Bundle H follow-up): each query opens its OWN DI scope and resolves a
|
||||
/// fresh <see cref="IAuditLogRepository"/> — and therefore a fresh
|
||||
/// <c>ScadaBridgeDbContext</c> — rather than sharing the scoped Blazor-circuit
|
||||
/// context. Without this, the Audit Log page's query-string auto-load
|
||||
/// (<c>/audit/log?correlationId=…</c>) races <c>AuditFilterBar.GetAllSitesAsync()</c>
|
||||
/// on the single circuit-scoped <c>ScadaBridgeDbContext</c>, 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 <c>AuditLogIngestActor</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second constructor takes an <see cref="IAuditLogRepository"/> directly — a
|
||||
/// test seam (mirroring <c>AuditLogIngestActor</c>'s dual ctor) so unit tests can
|
||||
/// substitute a stub without standing up a DI container.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
// Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers
|
||||
// the filter dropdown population. A 60s cache keeps rendering the filter
|
||||
// bar cheap — node membership changes (failover, scaling) surface within
|
||||
// a minute, which is acceptable for a filter affordance.
|
||||
private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);
|
||||
|
||||
// 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;
|
||||
|
||||
// Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
|
||||
// a stampede of concurrent filter-bar renders does not turn into N DB hits.
|
||||
private readonly object _sourceNodesLock = new();
|
||||
private IReadOnlyList<string>? _cachedSourceNodes;
|
||||
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — resolves <see cref="IAuditLogRepository"/> 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 the filter bar uses.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
|
||||
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
||||
public AuditLogQueryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICentralHealthAggregator healthAggregator)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
||||
}
|
||||
|
||||
/// <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 audit log repository instance to use directly.</param>
|
||||
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
||||
public AuditLogQueryService(
|
||||
IAuditLogRepository repository,
|
||||
ICentralHealthAggregator healthAggregator)
|
||||
{
|
||||
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int DefaultPageSize => 100;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditEvent>> 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<IAuditLogRepository>();
|
||||
return await repository.QueryAsync(filter, effective, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AuditLogKpiSnapshot> 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<IAuditLogRepository>();
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> 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<IAuditLogRepository>();
|
||||
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Fast path: a fresh cache entry served entirely from memory.
|
||||
lock (_sourceNodesLock)
|
||||
{
|
||||
if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
|
||||
{
|
||||
return _cachedSourceNodes;
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<string> snapshot;
|
||||
if (_injectedRepository is not null)
|
||||
{
|
||||
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
snapshot = await repository.GetDistinctSourceNodesAsync(ct);
|
||||
}
|
||||
|
||||
// Slow path: replace the cache entry. Concurrent slow paths can race
|
||||
// here — every winner stores a valid snapshot, so the last write wins
|
||||
// and no caller sees stale data. The double-check inside the lock is
|
||||
// deliberately omitted: redundant DB hits during a stampede are rare
|
||||
// (60s TTL) and cheaper than holding the lock across the await.
|
||||
lock (_sourceNodesLock)
|
||||
{
|
||||
_cachedSourceNodes = snapshot;
|
||||
_cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI facade over <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository"/>
|
||||
/// (#23 M7-T3). The Audit Log page's results grid talks to this service rather than
|
||||
/// the repository directly so tests can substitute a fake without spinning up EF
|
||||
/// Core, and so a future caching / shaping layer (e.g. server-side CSV streaming)
|
||||
/// can hang off the same seam.
|
||||
/// </summary>
|
||||
public interface IAuditLogQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a keyset-paged result page for <paramref name="filter"/>. When
|
||||
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
|
||||
/// rows with no cursor (first page). The repository orders by
|
||||
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
|
||||
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
|
||||
/// back as the cursor for the next page.
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter criteria applied to the audit log query.</param>
|
||||
/// <param name="paging">Optional paging cursor; defaults to first page when null.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||
AuditLogQueryFilter filter,
|
||||
AuditLogPaging? paging = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Default page size when callers don't specify one.</summary>
|
||||
int DefaultPageSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — returns the point-in-time KPI snapshot
|
||||
/// the Health dashboard's Audit tiles render. Composes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TotalEventsLastHour</c> + <c>ErrorEventsLastHour</c> from
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.GetKpiSnapshotAsync"/>
|
||||
/// (1-hour trailing window).</item>
|
||||
/// <item><c>BacklogTotal</c> from the sum of every site's
|
||||
/// <c>SiteHealthReport.SiteAuditBacklog.PendingCount</c> via
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.HealthMonitoring.ICentralHealthAggregator"/>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Repository + aggregator are read independently; if either source has no
|
||||
/// data the corresponding field is zero (a real signal — "no events" vs
|
||||
/// "no backlog" — rather than an error). The service does NOT swallow
|
||||
/// exceptions; the page wraps the call in a try/catch so a transient DB
|
||||
/// outage degrades the tile group to "unavailable" rather than killing the
|
||||
/// dashboard.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId feature (Task 10) — returns the full
|
||||
/// execution chain containing <paramref name="executionId"/> as a flat list
|
||||
/// of <see cref="ExecutionTreeNode"/>, delegating to
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||
/// The execution-chain tree view (<c>/audit/execution-tree</c>) assembles the
|
||||
/// returned flat list into a tree by joining
|
||||
/// <see cref="ExecutionTreeNode.ParentExecutionId"/> to a parent node's
|
||||
/// <see cref="ExecutionTreeNode.ExecutionId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A pure pass-through, mirroring <see cref="QueryAsync"/> — the production
|
||||
/// implementation opens its own DI scope per call so the tree page's
|
||||
/// auto-load never contends with the circuit-scoped <c>ScadaBridgeDbContext</c>.
|
||||
/// </remarks>
|
||||
/// <param name="executionId">Any execution id in the chain to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
|
||||
/// central <c>AuditLog</c> table, backing the Audit Log page's Node
|
||||
/// multi-select filter. The implementation caches the result for ~60s so
|
||||
/// rendering the filter bar never produces more than one DB hit per minute
|
||||
/// per circuit. The cache is process-wide — node membership changes
|
||||
/// (failover, scaling) surface within a minute, which is acceptable for a
|
||||
/// filter affordance.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user