docs: add XML doc comments across src + Sister Projects section in CLAUDE.md

Bulk CommentChecker pass: fills in <param>/<inheritdoc> tags on public
APIs across all 23 src/ projects so the doc-coverage gate is green. Also
adds a Sister Projects section to CLAUDE.md pointing at the MxAccess
Gateway and OtOpcUa sibling repos, and gitignores local credential
captures (*login*.txt) and the wonder-app-vd03 deploy/ artifacts.
This commit is contained in:
Joseph Doherty
2026-05-28 01:55:24 -04:00
parent 6731845473
commit 1eb6e972b0
381 changed files with 5788 additions and 532 deletions
+6
View File
@@ -40,3 +40,9 @@ data/
# Docker env2 runtime data
docker-env2/*/logs/
docker-env2/*/data/
# Local credentials / login captures — never commit
*login*.txt
# Sister-project deployment artifacts (not part of this solution)
/deploy/
+7
View File
@@ -18,6 +18,13 @@ When a change is requested, the default assumption is: update the design doc *an
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
## Sister Projects
Related repos cloned as sibling directories under `~/Desktop/` — referenced for context, not part of this solution:
- `~/Desktop/MxAccessGateway` — MxAccess Gateway (`https://gitea.dohertylan.com/dohertj2/mxaccessgw`).
- `~/Desktop/OtOpcUa` — OtOpcUa (`https://gitea.dohertylan.com/dohertj2/lmxopcua`).
## Document Conventions
- Requirements documents (high-level and component-level) live in `docs/requirements/`.
@@ -64,6 +64,7 @@ public sealed class AuditCentralHealthSnapshot
/// later from the Akka host) can push without a friend reference;
/// readers should call <see cref="SiteAuditTelemetryStalled"/>.
/// </summary>
/// <param name="evt">The event carrying the site ID and new stalled state.</param>
public void ApplyStalled(SiteAuditTelemetryStalledChanged evt)
{
if (evt is null) return;
@@ -52,6 +52,8 @@ public class AuditLogIngestActor : ReceiveActor
/// lifetime exceeds the test, so the actor reuses the same instance across
/// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
/// </summary>
/// <param name="repository">Audit log repository instance shared across all messages.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
public AuditLogIngestActor(
IAuditLogRepository repository,
ILogger<AuditLogIngestActor> logger)
@@ -77,6 +79,8 @@ public class AuditLogIngestActor : ReceiveActor
/// is a long-lived cluster singleton, so it cannot hold a scope across
/// messages.
/// </summary>
/// <param name="serviceProvider">Root service provider used to open a fresh scope per message.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
public AuditLogIngestActor(
IServiceProvider serviceProvider,
ILogger<AuditLogIngestActor> logger)
@@ -91,12 +95,7 @@ public class AuditLogIngestActor : ReceiveActor
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryAsync);
}
/// <summary>
/// Audit-write failures are best-effort by design (see alog.md §13): a
/// thrown exception in the ingest pipeline must not crash the actor.
/// Resume keeps the actor's state intact so the next batch is processed
/// against the same repository instance.
/// </summary>
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
@@ -60,6 +60,12 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>
/// Initializes the maintenance service with its required dependencies.
/// </summary>
/// <param name="scopeFactory">Scope factory used to open DI scopes for each maintenance run.</param>
/// <param name="options">Partition maintenance options (retention period, purge interval, etc.).</param>
/// <param name="logger">Logger for this service.</param>
public AuditLogPartitionMaintenanceService(
IServiceScopeFactory scopeFactory,
IOptions<AuditLogPartitionMaintenanceOptions> options,
@@ -61,6 +61,11 @@ public class AuditLogPurgeActor : ReceiveActor
private readonly ILogger<AuditLogPurgeActor> _logger;
private ICancelable? _timer;
/// <summary>Initializes a new instance of <see cref="AuditLogPurgeActor"/> and registers the tick handler.</summary>
/// <param name="services">DI service provider used to create scoped repository instances per tick.</param>
/// <param name="purgeOptions">Options controlling the purge interval.</param>
/// <param name="auditOptions">Options controlling retention policy (RetentionDays).</param>
/// <param name="logger">Logger instance.</param>
public AuditLogPurgeActor(
IServiceProvider services,
IOptions<AuditLogPurgeOptions> purgeOptions,
@@ -80,6 +85,7 @@ public class AuditLogPurgeActor : ReceiveActor
ReceiveAsync<PurgeTick>(_ => OnTickAsync());
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
@@ -92,17 +98,14 @@ public class AuditLogPurgeActor : ReceiveActor
sender: Self);
}
/// <inheritdoc />
protected override void PostStop()
{
_timer?.Cancel();
base.PostStop();
}
/// <summary>
/// Resume keeps the singleton alive across any leaked exception. Restart
/// would re-run PreStart and reschedule the timer (harmless but wasteful);
/// Stop is wrong because the singleton must keep ticking until shutdown.
/// </summary>
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
@@ -47,6 +47,10 @@ public sealed class CentralAuditRedactionFailureCounter : IAuditRedactionFailure
{
private readonly AuditCentralHealthSnapshot _snapshot;
/// <summary>
/// Initializes a new <see cref="CentralAuditRedactionFailureCounter"/> backed by the supplied snapshot.
/// </summary>
/// <param name="snapshot">The central health snapshot that accumulates the redaction failure count.</param>
public CentralAuditRedactionFailureCounter(AuditCentralHealthSnapshot snapshot)
{
_snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
@@ -66,6 +66,11 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// provider simply leaves SourceNode at whatever the caller set (often
/// null, which is the legacy behaviour).
/// </summary>
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> logger,
@@ -80,12 +85,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
_nodeIdentity = nodeIdentity;
}
/// <summary>
/// Persists <paramref name="evt"/> into the central <c>AuditLog</c> table
/// idempotently on <see cref="AuditEvent.EventId"/>. Stamps
/// <see cref="AuditEvent.IngestedAtUtc"/> from the central-side clock.
/// Internal failures are logged and swallowed — never thrown.
/// </summary>
/// <inheritdoc />
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
if (evt is null)
@@ -37,6 +37,10 @@ public interface IPullAuditEventsClient
/// rows ordered oldest-first AND a <c>MoreAvailable</c> flag the actor
/// uses to decide whether to fire another pull immediately.
/// </summary>
/// <param name="siteId">The identifier of the site to pull audit events from.</param>
/// <param name="sinceUtc">Only events with an <c>OccurredAtUtc</c> at or after this cursor time are returned.</param>
/// <param name="batchSize">Maximum number of events to return per call.</param>
/// <param name="ct">Cancellation token.</param>
Task<PullAuditEventsResponse> PullAsync(
string siteId,
DateTime sinceUtc,
@@ -22,6 +22,7 @@ public interface ISiteEnumerator
/// on the next tick. Implementations should reflect adds/removes promptly
/// — the actor calls this once per tick.
/// </summary>
/// <param name="ct">Cancellation token for the async enumeration.</param>
Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default);
}
@@ -95,6 +95,14 @@ public class SiteAuditReconciliationActor : ReceiveActor
private ICancelable? _timer;
/// <summary>
/// Initializes the reconciliation actor with its dependencies and registers the tick handler.
/// </summary>
/// <param name="sites">Enumerates the known sites to reconcile.</param>
/// <param name="client">Client used to pull audit events from individual sites.</param>
/// <param name="services">Root service provider for opening a per-tick DI scope.</param>
/// <param name="options">Reconciliation configuration (interval, page size).</param>
/// <param name="logger">Logger for reconciliation diagnostics.</param>
public SiteAuditReconciliationActor(
ISiteEnumerator sites,
IPullAuditEventsClient client,
@@ -117,6 +125,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
ReceiveAsync<ReconciliationTick>(_ => OnTickAsync());
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
@@ -129,6 +138,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
sender: Self);
}
/// <inheritdoc />
protected override void PostStop()
{
_timer?.Cancel();
@@ -301,11 +311,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
}
/// <summary>
/// Resume on any unhandled exception inside the receive — the singleton
/// MUST stay alive even if the per-tick try/catch leaks. Restart would
/// reset the cursors (safe but wasteful); Resume preserves them.
/// </summary>
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
@@ -67,6 +67,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// <c>SiteAuditTelemetryStalledTrackerTests</c> use the ActorSystem ctor
/// via Akka.TestKit so they exercise the production subscribe path.
/// </remarks>
/// <param name="eventStream">The actor system event stream to observe.</param>
public SiteAuditTelemetryStalledTracker(EventStream eventStream)
: this(eventStream, snapshot: null)
{
@@ -80,6 +81,8 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// subscribe (no actor system), but tests that drive the tracker via
/// <see cref="Apply"/> get the snapshot push for free.
/// </summary>
/// <param name="eventStream">The actor system event stream to observe.</param>
/// <param name="snapshot">Optional central health snapshot to mirror stalled-state changes into.</param>
public SiteAuditTelemetryStalledTracker(EventStream eventStream, AuditCentralHealthSnapshot? snapshot)
{
_eventStream = eventStream ?? throw new ArgumentNullException(nameof(eventStream));
@@ -94,6 +97,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// <see cref="SiteAuditTelemetryStalledChanged"/> updates the latched
/// per-site map. <see cref="Dispose"/> tears the subscriber down.
/// </summary>
/// <param name="actorSystem">The actor system whose EventStream will be subscribed.</param>
public SiteAuditTelemetryStalledTracker(ActorSystem actorSystem)
: this(actorSystem, snapshot: null)
{
@@ -105,6 +109,8 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// shared <see cref="AuditCentralHealthSnapshot"/> so the central health
/// surface sees per-site stalled state without re-reading the tracker.
/// </summary>
/// <param name="actorSystem">The actor system whose EventStream will be subscribed.</param>
/// <param name="snapshot">Optional central health snapshot to mirror stalled-state changes into.</param>
public SiteAuditTelemetryStalledTracker(ActorSystem actorSystem, AuditCentralHealthSnapshot? snapshot)
{
ArgumentNullException.ThrowIfNull(actorSystem);
@@ -136,6 +142,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// internally so tests against the bare-stream ctor can still drive the
/// tracker, but the production path always goes through the actor.
/// </summary>
/// <param name="evt">The stalled-state change event to apply.</param>
internal void Apply(SiteAuditTelemetryStalledChanged evt)
{
if (evt is null) return;
@@ -147,6 +154,9 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
_snapshot?.ApplyStalled(evt);
}
/// <summary>
/// Disposes the tracker and tears down the internal subscriber actor.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -173,12 +183,17 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
{
private readonly SiteAuditTelemetryStalledTracker _parent;
/// <summary>
/// Initializes a new subscriber actor that forwards events to the given tracker.
/// </summary>
/// <param name="parent">The parent tracker whose <see cref="Apply"/> method will be called for each event.</param>
public StalledChangedSubscriber(SiteAuditTelemetryStalledTracker parent)
{
_parent = parent;
Receive<SiteAuditTelemetryStalledChanged>(evt => _parent.Apply(evt));
}
/// <inheritdoc />
protected override void PostStop()
{
Context.System.EventStream.Unsubscribe(Self, typeof(SiteAuditTelemetryStalledChanged));
@@ -103,6 +103,9 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
/// counter from the container; a NoOp default is registered in
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// </summary>
/// <param name="options">Live-reloadable audit log options.</param>
/// <param name="logger">Logger for redaction diagnostics.</param>
/// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
public DefaultAuditPayloadFilter(
IOptionsMonitor<AuditLogOptions> options,
ILogger<DefaultAuditPayloadFilter> logger,
@@ -113,6 +116,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
}
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
@@ -573,8 +577,11 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{
public static readonly CompiledRegex Invalid = new(null);
/// <summary>Gets the compiled <see cref="System.Text.RegularExpressions.Regex"/>, or <c>null</c> when the pattern was invalid.</summary>
public Regex? Regex { get; }
/// <summary>Initializes a new <see cref="CompiledRegex"/> wrapping the given compiled regex instance.</summary>
/// <param name="regex">The pre-compiled regex, or <c>null</c> to represent an invalid pattern.</param>
public CompiledRegex(Regex? regex) => Regex = regex;
}
}
@@ -26,5 +26,6 @@ public interface IAuditPayloadFilter
/// and return a filtered copy. MUST NOT throw — on internal failure, over-redact
/// and surface the failure via the audit-redaction-failure health metric.
/// </summary>
/// <param name="rawEvent">The unfiltered audit event to process.</param>
AuditEvent Apply(AuditEvent rawEvent);
}
@@ -53,6 +53,9 @@ public static class ServiceCollectionExtensions
/// and the site-→central telemetry collaborators. Idempotent re-registration
/// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection to register into.</param>
/// <param name="config">Application configuration used to bind <see cref="AuditLogOptions"/> and related options sections.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
@@ -252,6 +255,8 @@ public static class ServiceCollectionExtensions
/// ships in M6.
/// </para>
/// </remarks>
/// <param name="services">The service collection to register into.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
@@ -290,6 +295,9 @@ public static class ServiceCollectionExtensions
/// from any composition root" invariant.
/// </para>
/// </remarks>
/// <param name="services">The service collection to register into.</param>
/// <param name="config">Application configuration used to bind partition maintenance options.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddAuditLogCentralMaintenance(
this IServiceCollection services,
IConfiguration config)
@@ -44,6 +44,11 @@ public sealed class FallbackAuditWriter : IAuditWriter
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
/// always passes the real filter through.
/// </summary>
/// <param name="primary">The primary audit writer (typically the SQLite writer).</param>
/// <param name="ring">Drop-oldest ring buffer used to stash events when the primary fails.</param>
/// <param name="failureCounter">Counter incremented on each primary failure for health reporting.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="filter">Optional payload filter applied before writing; null means no filtering.</param>
public FallbackAuditWriter(
IAuditWriter primary,
RingBufferFallback ring,
@@ -58,6 +63,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
_filter = filter; // null = no-op pass-through; see WriteAsync.
}
/// <inheritdoc />
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
@@ -38,6 +38,8 @@ public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionF
{
private readonly ISiteHealthCollector _collector;
/// <summary>Initializes the counter with the site health collector it bridges into.</summary>
/// <param name="collector">The site health collector that receives the incremented redaction-failure count.</param>
public HealthMetricsAuditRedactionFailureCounter(ISiteHealthCollector collector)
{
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
@@ -23,6 +23,10 @@ public sealed class HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCo
{
private readonly ISiteHealthCollector _collector;
/// <summary>
/// Initializes a new <see cref="HealthMetricsAuditWriteFailureCounter"/> backed by the given health collector.
/// </summary>
/// <param name="collector">The site health collector to increment on each audit write failure.</param>
public HealthMetricsAuditWriteFailureCounter(ISiteHealthCollector collector)
{
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
@@ -37,6 +37,8 @@ public sealed class RingBufferFallback
/// </summary>
public event Action? RingBufferOverflowed;
/// <summary>Initializes the ring buffer with the specified fixed capacity.</summary>
/// <param name="capacity">Maximum number of events to buffer; must be greater than zero. Default is 1024.</param>
public RingBufferFallback(int capacity = 1024)
{
if (capacity <= 0)
@@ -62,6 +64,8 @@ public sealed class RingBufferFallback
/// <see langword="false"/> only when the ring has been
/// <see cref="Complete"/>-d.
/// </summary>
/// <param name="evt">The audit event to enqueue.</param>
/// <returns><see langword="true"/> if enqueued (or enqueued with overflow); <see langword="false"/> when the channel is completed.</returns>
public bool TryEnqueue(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
@@ -91,6 +95,7 @@ public sealed class RingBufferFallback
/// been called. Callers that only want to drain what's currently buffered
/// must call <see cref="Complete"/> first.
/// </summary>
/// <param name="cancellationToken">Cancellation token to abort the async enumeration.</param>
public async IAsyncEnumerable<AuditEvent> DrainAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -105,6 +110,8 @@ public sealed class RingBufferFallback
/// <see cref="FallbackAuditWriter"/> recovery path. Returns
/// <see langword="false"/> when the ring is empty.
/// </summary>
/// <param name="evt">When this returns <see langword="true"/>, contains the dequeued event.</param>
/// <returns><see langword="true"/> if an event was dequeued; <see langword="false"/> if the ring is empty.</returns>
public bool TryDequeue(out AuditEvent evt) => _channel.Reader.TryRead(out evt!);
/// <summary>
@@ -52,6 +52,11 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>Initializes a new instance of <see cref="SiteAuditBacklogReporter"/>.</summary>
/// <param name="queue">The site audit queue used to probe the backlog count.</param>
/// <param name="collector">The site health collector that receives the backlog snapshot.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="refreshInterval">Poll interval override; defaults to <see cref="DefaultRefreshInterval"/> (30 s).</param>
public SiteAuditBacklogReporter(
ISiteAuditQueue queue,
ISiteHealthCollector collector,
@@ -48,6 +48,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private readonly Task _writerLoop;
private bool _disposed;
/// <summary>Initializes a new instance of the SqliteAuditWriter class.</summary>
/// <param name="options">Configuration options for the audit writer.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="nodeIdentity">Node identity provider.</param>
/// <param name="connectionStringOverride">Optional connection string override.</param>
public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> logger,
@@ -186,14 +191,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
alter.ExecuteNonQuery();
}
/// <summary>
/// Enqueue an event for durable persistence. The returned <see cref="Task"/>
/// completes once the event has been INSERTed (or, in the duplicate-EventId
/// case, recognised as already present); it faults only if the writer loop
/// itself collapses. The enqueue side never blocks on disk I/O — it only
/// awaits the bounded channel's back-pressure when the writer is briefly
/// behind.
/// </summary>
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
@@ -386,12 +384,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
/// <summary>
/// Returns up to <paramref name="limit"/> rows in <see cref="AuditForwardState.Pending"/>,
/// oldest <see cref="AuditEvent.OccurredAtUtc"/> first, with <see cref="AuditEvent.EventId"/>
/// as the deterministic tiebreaker. Called by Bundle D's site telemetry
/// actor to build a batch for the gRPC push.
/// </summary>
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{
if (limit <= 0)
@@ -443,6 +436,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <see cref="ReadPendingSinceAsync"/>, which also returns
/// <see cref="AuditForwardState.Pending"/> rows).
/// </summary>
/// <param name="limit">Maximum number of rows to return.</param>
/// <param name="ct">Cancellation token.</param>
public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
{
if (limit <= 0)
@@ -481,11 +476,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
/// <summary>
/// Flips the supplied EventIds from <see cref="AuditForwardState.Pending"/> to
/// <see cref="AuditForwardState.Forwarded"/> in a single UPDATE. Non-existent
/// or already-forwarded ids are no-ops.
/// </summary>
/// <inheritdoc />
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
@@ -520,15 +511,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
/// <summary>
/// M6 reconciliation-pull read: returns up to <paramref name="batchSize"/> rows
/// whose <c>OccurredAtUtc &gt;= sinceUtc</c> and whose <see cref="AuditForwardState"/>
/// is still <see cref="AuditForwardState.Pending"/> or
/// <see cref="AuditForwardState.Forwarded"/>. Forwarded rows are included so the
/// brief race window between a site-Forwarded ack and central ingest cannot
/// silently drop rows; central dedups on <see cref="AuditEvent.EventId"/>.
/// Ordered oldest <see cref="AuditEvent.OccurredAtUtc"/> first, EventId tiebreaker.
/// </summary>
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, CancellationToken ct = default)
{
@@ -575,13 +558,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
/// <summary>
/// M6 reconciliation-pull commit: flips the supplied EventIds to
/// <see cref="AuditForwardState.Reconciled"/>, but ONLY for rows currently in
/// <see cref="AuditForwardState.Pending"/> or <see cref="AuditForwardState.Forwarded"/>.
/// Rows already in <see cref="AuditForwardState.Reconciled"/> are left untouched
/// (idempotent re-call). Non-existent ids are silent no-ops.
/// </summary>
/// <inheritdoc />
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
@@ -616,22 +593,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
/// <summary>
/// M6 Bundle E (T6) health-metric surface: returns a point-in-time snapshot
/// of the site queue's pending count, the oldest pending row's
/// <see cref="AuditEvent.OccurredAtUtc"/>, and the on-disk file size. Called
/// by the site-side <c>SiteAuditBacklogReporter</c> hosted service on its
/// 30 s tick to refresh the <c>SiteHealthReport.SiteAuditBacklog</c> field.
/// </summary>
/// <remarks>
/// The pending-count + oldest-row queries run inside the same write lock as
/// the hot-path INSERT batch so the snapshot is consistent against the
/// connection's view (no torn read of an in-flight transaction). The on-disk
/// size lookup happens OUTSIDE the lock — it's a stat() call on the file
/// path and doesn't touch the connection. In-memory and missing files
/// return 0 bytes (the snapshot is for ops dashboards, not a correctness
/// invariant).
/// </remarks>
/// <inheritdoc />
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
{
int pendingCount;
@@ -731,11 +693,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
};
}
/// <summary>Disposes the audit writer and releases resources.</summary>
public void Dispose()
{
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
/// <summary>Asynchronously disposes the audit writer and releases resources.</summary>
public async ValueTask DisposeAsync()
{
Task? writerLoop;
@@ -779,13 +743,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>An audit event awaiting persistence by the background writer.</summary>
private sealed class PendingAuditEvent
{
/// <summary>Initializes a new instance of the PendingAuditEvent class.</summary>
/// <param name="evt">The audit event to persist.</param>
public PendingAuditEvent(AuditEvent evt)
{
Event = evt;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>The audit event to persist.</summary>
public AuditEvent Event { get; }
/// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; }
}
}
@@ -48,6 +48,10 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
/// </summary>
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>Initializes a new <see cref="CachedCallLifecycleBridge"/> with the given telemetry forwarder, logger, and optional node identity provider.</summary>
/// <param name="forwarder">The telemetry forwarder used to ship cached-call lifecycle events to central.</param>
/// <param name="logger">Logger for bridge diagnostics.</param>
/// <param name="nodeIdentity">Optional node identity provider used to stamp <c>SourceNode</c> on emitted telemetry rows.</param>
public CachedCallLifecycleBridge(
ICachedCallTelemetryForwarder forwarder,
ILogger<CachedCallLifecycleBridge> logger,
@@ -70,6 +70,10 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
/// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain.
/// </summary>
/// <param name="auditWriter">Writer used to persist audit events from the telemetry packet.</param>
/// <param name="trackingStore">Optional store for updating operation tracking state; null on central nodes.</param>
/// <param name="logger">Logger for this forwarder.</param>
/// <param name="nodeIdentity">Optional provider of the current node name stamped on emitted rows.</param>
public CachedCallTelemetryForwarder(
IAuditWriter auditWriter,
IOperationTrackingStore? trackingStore,
@@ -82,13 +86,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
_nodeIdentity = nodeIdentity;
}
/// <summary>
/// Fan out one combined-telemetry packet to the audit writer and the
/// tracking store. Returns once both halves have been attempted (success
/// OR logged failure). NEVER throws — exceptions are caught per-half and
/// logged at warning level so the calling script's outbound action is not
/// disturbed.
/// </summary>
/// <inheritdoc />
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(telemetry);
@@ -20,6 +20,8 @@ public interface ISiteStreamAuditClient
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
/// in the site SQLite queue.
/// </summary>
/// <param name="batch">The batch of audit events to forward.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary>
@@ -38,5 +40,7 @@ public interface ISiteStreamAuditClient
/// DI default (used by central and test composition roots) returns an empty
/// ack so no rows are flipped.
/// </remarks>
/// <param name="batch">The batch of cached-call telemetry packets to forward.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
}
@@ -43,6 +43,11 @@ public class SiteAuditTelemetryActor : ReceiveActor
private readonly ILogger<SiteAuditTelemetryActor> _logger;
private ICancelable? _pendingTick;
/// <summary>Initializes the actor with its drain queue, gRPC client, options, and logger.</summary>
/// <param name="queue">The site-local SQLite audit queue to drain.</param>
/// <param name="client">The gRPC client used to push audit events to central.</param>
/// <param name="options">Telemetry options controlling drain intervals and batch size.</param>
/// <param name="logger">Logger instance.</param>
public SiteAuditTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client,
@@ -62,6 +67,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
ReceiveAsync<Drain>(_ => OnDrainAsync());
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
@@ -71,6 +77,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
ScheduleNext(TimeSpan.FromSeconds(_options.BusyIntervalSeconds));
}
/// <inheritdoc />
protected override void PostStop()
{
_pendingTick?.Cancel();
+11
View File
@@ -2,9 +2,14 @@ using System.Text.Json;
namespace ScadaLink.CLI;
/// <summary>
/// Resolved CLI configuration combining config file values, environment variable overrides, and per-invocation credentials.
/// </summary>
public class CliConfig
{
/// <summary>Base URL of the ScadaLink Management API (e.g. http://localhost:9000).</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format for CLI commands; defaults to "json".</summary>
public string DefaultFormat { get; set; } = "json";
/// <summary>
@@ -21,6 +26,10 @@ public class CliConfig
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Loads CLI configuration by merging the config file, environment variables, and credential env vars.
/// </summary>
/// <returns>A populated <see cref="CliConfig"/> instance.</returns>
public static CliConfig Load()
{
var config = new CliConfig();
@@ -66,7 +75,9 @@ public class CliConfig
private class CliConfigFile
{
/// <summary>Management API URL from the config file.</summary>
public string? ManagementUrl { get; set; }
/// <summary>Default output format from the config file.</summary>
public string? DefaultFormat { get; set; }
}
}
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class ApiMethodCommands
{
/// <summary>
/// Builds the <c>api-method</c> CLI command group with subcommands for managing inbound API methods.
/// </summary>
/// <param name="urlOption">Global option for the management URL.</param>
/// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("api-method") { Description = "Manage inbound API methods" };
@@ -9,12 +9,37 @@ namespace ScadaLink.CLI.Commands;
/// </summary>
public sealed class AuditConnection
{
/// <summary>
/// The management URL, or null if resolution failed.
/// </summary>
public string? Url { get; init; }
/// <summary>
/// The username for authentication, or null if resolution failed.
/// </summary>
public string? Username { get; init; }
/// <summary>
/// The password for authentication, or null if resolution failed.
/// </summary>
public string? Password { get; init; }
/// <summary>
/// Error message if resolution failed, or null.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Error code if resolution failed, or null.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// Creates a failed connection with an error message and code.
/// </summary>
/// <param name="error">The error message.</param>
/// <param name="code">The error code.</param>
/// <returns>A failed AuditConnection.</returns>
public static AuditConnection Fail(string error, string code)
=> new() { Error = error, ErrorCode = code };
}
@@ -28,6 +53,14 @@ public sealed class AuditConnection
/// </summary>
public static class AuditCommandHelpers
{
/// <summary>
/// Resolves management API connection details from command line arguments, config file, or environment variables.
/// </summary>
/// <param name="result">The parsed command line arguments.</param>
/// <param name="urlOption">The URL option.</param>
/// <param name="usernameOption">The username option.</param>
/// <param name="passwordOption">The password option.</param>
/// <returns>The resolved connection details, or a failure result.</returns>
public static AuditConnection ResolveConnection(
ParseResult result,
Option<string> urlOption,
@@ -67,6 +100,12 @@ public static class AuditCommandHelpers
return new AuditConnection { Url = url, Username = username, Password = password };
}
/// <summary>
/// Resolves the output format from command line arguments, config file, or defaults to "table".
/// </summary>
/// <param name="result">The parsed command line arguments.</param>
/// <param name="formatOption">The format option.</param>
/// <returns>The resolved format string.</returns>
public static string ResolveFormat(ParseResult result, Option<string> formatOption)
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
}
@@ -11,6 +11,13 @@ namespace ScadaLink.CLI.Commands;
/// </summary>
public static class AuditCommands
{
/// <summary>
/// Builds the <c>audit</c> command group with query, export, and verify-chain sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("audit") { Description = "Query and export the centralized audit log" };
@@ -13,15 +13,45 @@ namespace ScadaLink.CLI.Commands;
/// </summary>
public sealed class AuditExportArgs
{
/// <summary>
/// Start timestamp for the export time window.
/// </summary>
public string Since { get; set; } = string.Empty;
/// <summary>
/// End timestamp for the export time window.
/// </summary>
public string Until { get; set; } = string.Empty;
/// <summary>
/// Export format (e.g., 'json', 'csv', 'parquet').
/// </summary>
public string Format { get; set; } = string.Empty;
/// <summary>
/// Output file path for the exported audit log.
/// </summary>
public string Output { get; set; } = string.Empty;
/// <summary>
/// Channel filter values (repeated query parameter).
/// </summary>
public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>
/// Kind filter values (repeated query parameter).
/// </summary>
public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>
/// Status filter values (repeated query parameter).
/// </summary>
public string[] Status { get; set; } = Array.Empty<string>();
/// <summary>
/// Site identifier filter values (repeated query parameter).
/// </summary>
public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>
/// Optional target system filter.
/// </summary>
public string? Target { get; set; }
/// <summary>
/// Optional actor/user filter.
/// </summary>
public string? Actor { get; set; }
}
@@ -41,6 +71,8 @@ public static class AuditExportHelpers
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary>
/// <param name="args">The export arguments containing filters and format.</param>
/// <param name="now">The current time for resolving relative time specifications.</param>
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{
var parts = new List<string>();
@@ -79,6 +111,10 @@ public static class AuditExportHelpers
/// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
/// server message and returns a non-zero exit code.
/// </summary>
/// <param name="client">The management HTTP client for API communication.</param>
/// <param name="args">The export arguments containing filters and output file path.</param>
/// <param name="output">Text writer for command output messages.</param>
/// <param name="now">The current time for resolving relative time specifications.</param>
public static async Task<int> RunExportAsync(
ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
{
@@ -10,6 +10,8 @@ namespace ScadaLink.CLI.Commands;
public interface IAuditFormatter
{
/// <summary>Renders one page of events. Called once per fetched page.</summary>
/// <param name="events">The audit events on this page.</param>
/// <param name="output">Writer to render the formatted output to.</param>
void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
}
@@ -21,6 +23,7 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
{
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{
foreach (var evt in events)
@@ -35,6 +38,11 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
/// </summary>
public static class AuditFormatterFactory
{
/// <summary>
/// Returns an <see cref="IAuditFormatter"/> for the given format name.
/// </summary>
/// <param name="format">Format name; <c>table</c> selects the table formatter, any other value selects JSONL.</param>
/// <param name="notices">Writer for notice messages emitted during formatting.</param>
public static IAuditFormatter Create(string format, TextWriter notices)
{
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
@@ -32,6 +32,8 @@ public static class AuditLogCommands
/// an alias of <c>audit-config</c> — so this only adds the migration warning.
/// Factored out of <c>Program.cs</c> so it is unit-testable without spawning a process.
/// </summary>
/// <param name="args">The raw command-line arguments passed to the CLI.</param>
/// <param name="stderr">The text writer to emit the deprecation warning to.</param>
public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
{
if (args.Length > 0
@@ -41,6 +43,13 @@ public static class AuditLogCommands
}
}
/// <summary>
/// Builds the <c>audit-config</c> command (with the deprecated <c>audit-log</c> alias) and its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
@@ -15,18 +15,31 @@ namespace ScadaLink.CLI.Commands;
/// </summary>
public sealed class AuditQueryArgs
{
/// <summary>Start time spec (relative like 1h, or absolute ISO-8601).</summary>
public string? Since { get; set; }
/// <summary>End time spec (relative like 7d, or absolute ISO-8601).</summary>
public string? Until { get; set; }
/// <summary>Multi-valued channel filter.</summary>
public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued audit event kind filter.</summary>
public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued status filter.</summary>
public string[] Status { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued site ID filter.</summary>
public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>Target system or service filter.</summary>
public string? Target { get; set; }
/// <summary>Actor (user or system) filter.</summary>
public string? Actor { get; set; }
/// <summary>Operation correlation ID filter.</summary>
public string? CorrelationId { get; set; }
/// <summary>Script execution ID filter.</summary>
public string? ExecutionId { get; set; }
/// <summary>Parent execution ID filter.</summary>
public string? ParentExecutionId { get; set; }
/// <summary>Filter for errors only (status=Failed).</summary>
public bool ErrorsOnly { get; set; }
/// <summary>Page size for pagination.</summary>
public int PageSize { get; set; } = 100;
}
@@ -45,6 +58,8 @@ public static class AuditQueryHelpers
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
/// </summary>
/// <param name="spec">The time specification string.</param>
/// <param name="now">The current time used as reference for relative specs.</param>
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now)
{
@@ -84,6 +99,10 @@ public static class AuditQueryHelpers
/// server's multi-value <c>IN (…)</c> filter receives the full set. <c>--errors-only</c>
/// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary>
/// <param name="args">The audit query arguments.</param>
/// <param name="now">The current time for resolving relative time specs.</param>
/// <param name="afterOccurredAtUtc">Optional keyset cursor timestamp.</param>
/// <param name="afterEventId">Optional keyset cursor event ID.</param>
public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
{
@@ -144,6 +163,12 @@ public static class AuditQueryHelpers
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
/// process exit code (0 success, non-zero on HTTP/transport error).
/// </summary>
/// <param name="client">The management HTTP client.</param>
/// <param name="args">The audit query arguments.</param>
/// <param name="fetchAll">Whether to follow pagination cursors.</param>
/// <param name="formatter">The audit result formatter.</param>
/// <param name="output">The output writer for results.</param>
/// <param name="now">The current time for resolving relative time specs.</param>
public static async Task<int> RunQueryAsync(
ManagementHttpClient client,
AuditQueryArgs args,
@@ -13,6 +13,7 @@ public static class AuditVerifyChainHelpers
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
/// </summary>
/// <param name="month">The month string to validate in YYYY-MM format.</param>
public static bool IsValidMonth(string? month)
=> !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
@@ -15,6 +15,12 @@ public static class BundleCommands
{
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5);
/// <summary>Builds the <c>bundle</c> command group with export, preview, and import sub-commands.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option.</param>
/// <param name="passwordOption">Shared password option.</param>
/// <returns>The configured <see cref="Command"/> for the bundle group.</returns>
public static Command Build(
Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption)
@@ -7,6 +7,16 @@ namespace ScadaLink.CLI.Commands;
internal static class CommandHelpers
{
/// <summary>
/// Resolves the management URL, credentials, and output format, then sends <paramref name="command"/>
/// to the management API and returns the process exit code.
/// </summary>
/// <param name="result">Parsed command-line result from which option values are read.</param>
/// <param name="urlOption">Option that supplies the management URL override.</param>
/// <param name="formatOption">Option that supplies the output format override.</param>
/// <param name="usernameOption">Option that supplies the username override.</param>
/// <param name="passwordOption">Option that supplies the password override.</param>
/// <param name="command">The management command object to send.</param>
internal static async Task<int> ExecuteCommandAsync(
ParseResult result,
Option<string> urlOption,
@@ -69,6 +79,9 @@ internal static class CommandHelpers
/// is used, otherwise <c>json</c>. The <c>--format</c> option must not declare a
/// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
/// </summary>
/// <param name="result">Parsed command-line result.</param>
/// <param name="formatOption">The <c>--format</c> option definition.</param>
/// <param name="config">Loaded CLI configuration providing the default format fallback.</param>
internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
{
// GetResult returns non-null only when the option was actually present on the
@@ -87,6 +100,8 @@ internal static class CommandHelpers
/// Resolves a single credential: an explicit command-line value wins, otherwise the
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
/// </summary>
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
/// <param name="envValue">Fallback value from the config file or environment variable.</param>
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
@@ -96,6 +111,7 @@ internal static class CommandHelpers
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
/// an unhandled <see cref="UriFormatException"/>.
/// </summary>
/// <param name="url">URL string to validate.</param>
internal static bool IsValidManagementUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
@@ -105,6 +121,11 @@ internal static class CommandHelpers
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
/// <summary>
/// Writes the management response to stdout and returns the appropriate process exit code.
/// </summary>
/// <param name="response">Response received from the management API.</param>
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
internal static int HandleResponse(ManagementResponse response, string format)
{
if (response.JsonData != null)
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class DataConnectionCommands
{
/// <summary>
/// Builds the <c>data-connection</c> command group and all its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("data-connection") { Description = "Manage data connections" };
@@ -4,8 +4,17 @@ using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI.Commands;
/// <summary>
/// CLI commands for managing database connection definitions.
/// </summary>
public static class DbConnectionCommands
{
/// <summary>Builds the <c>db-connection</c> command with list, get, create, update, and delete sub-commands.</summary>
/// <param name="urlOption">Global URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
/// <returns>The configured <c>db-connection</c> command.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("db-connection") { Description = "Manage database connections" };
@@ -10,6 +10,11 @@ namespace ScadaLink.CLI.Commands;
public static class DebugCommands
{
/// <summary>Builds the <c>debug</c> command with its subcommands using the given shared CLI options.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("debug") { Description = "Runtime debugging" };
@@ -25,6 +25,8 @@ internal static class DebugStreamHelpers
/// (Ctrl+C during connect) is a graceful shutdown — exit 0, no error printed.
/// Anything else is a genuine connection failure — exit 1.
/// </summary>
/// <param name="ex">The exception thrown by HubConnection.StartAsync.</param>
/// <param name="cancellationRequested">True when the user requested cancellation (Ctrl+C) before the exception was thrown.</param>
internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
{
if (cancellationRequested && ex is OperationCanceledException)
@@ -40,6 +42,7 @@ internal static class DebugStreamHelpers
/// a brief grace period covers a termination that races with cancellation. If no
/// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
/// </summary>
/// <param name="exitTask">The task whose result is the intended exit code, set by OnStreamTerminated or the Closed handler.</param>
internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
{
if (exitTask.IsCompletedSuccessfully)
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class DeployCommands
{
/// <summary>
/// Builds the <c>deploy</c> command group with all sub-commands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("deploy") { Description = "Deployment operations" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class ExternalSystemCommands
{
/// <summary>
/// Builds the <c>external-system</c> CLI command group with subcommands for managing external systems.
/// </summary>
/// <param name="urlOption">Global option for the management URL.</param>
/// <param name="formatOption">Global option for the output format.</param>
/// <param name="usernameOption">Global option for the authentication username.</param>
/// <param name="passwordOption">Global option for the authentication password.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("external-system") { Description = "Manage external systems" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class HealthCommands
{
/// <summary>
/// Builds the <c>health</c> command group with summary, site, event-log, and parked-message sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("health") { Description = "Health monitoring" };
@@ -6,6 +6,14 @@ namespace ScadaLink.CLI.Commands;
public static class InstanceCommands
{
/// <summary>
/// Builds the instance command and its subcommands.
/// </summary>
/// <param name="urlOption">The URL option.</param>
/// <param name="formatOption">The format option.</param>
/// <param name="usernameOption">The username option.</param>
/// <param name="passwordOption">The password option.</param>
/// <returns>The instance command with all subcommands.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("instance") { Description = "Manage instances" };
@@ -71,6 +79,10 @@ public static class InstanceCommands
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
/// has the wrong type.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="bindings">The parsed bindings list, or null if parsing fails.</param>
/// <param name="error">The error message if parsing fails, or null on success.</param>
/// <returns>True if parsing succeeded; false otherwise.</returns>
internal static bool TryParseBindings(
string json,
out List<ConnectionBinding>? bindings,
@@ -126,6 +138,10 @@ public static class InstanceCommands
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
/// when the JSON is malformed or null.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="overrides">The parsed overrides dictionary, or null if parsing fails.</param>
/// <param name="error">The error message if parsing fails, or null on success.</param>
/// <returns>True if parsing succeeded; false otherwise.</returns>
internal static bool TryParseOverrides(
string json,
out Dictionary<string, string?>? overrides,
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class NotificationCommands
{
/// <summary>
/// Builds the <c>notification</c> command group with sub-commands for managing notification lists and SMTP configuration.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("notification") { Description = "Manage notification lists" };
@@ -123,6 +130,7 @@ public static class NotificationCommands
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
/// null when omitted so the server-side handler preserves the existing values.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{
var id = result.GetValue(SmtpIdOption);
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class SecurityCommands
{
/// <summary>
/// Builds the <c>security</c> command group with API key, role mapping, and scope rule subcommands.
/// </summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("security") { Description = "Manage security settings" };
@@ -6,6 +6,12 @@ namespace ScadaLink.CLI.Commands;
public static class SharedScriptCommands
{
/// <summary>Builds the <c>shared-script</c> command group with list, get, create, update, and delete sub-commands.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option.</param>
/// <param name="passwordOption">Shared password option.</param>
/// <returns>The configured <see cref="Command"/> for the shared-script group.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("shared-script") { Description = "Manage shared scripts" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class SiteCommands
{
/// <summary>
/// Builds the <c>site</c> command group and all its subcommands.
/// </summary>
/// <param name="urlOption">Global management URL option.</param>
/// <param name="formatOption">Global output format option.</param>
/// <param name="usernameOption">Global username option.</param>
/// <param name="passwordOption">Global password option.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("site") { Description = "Manage sites" };
@@ -28,6 +28,7 @@ public sealed class TableAuditFormatter : IAuditFormatter
("httpStatus", "HttpStatus", 10),
};
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{
// Build every cell first so column widths account for the actual data.
@@ -6,6 +6,11 @@ namespace ScadaLink.CLI.Commands;
public static class TemplateCommands
{
/// <summary>Builds the <c>template</c> command with its subcommands using the given shared CLI options.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option for authentication.</param>
/// <param name="passwordOption">Shared password option for authentication.</param>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var command = new Command("template") { Description = "Manage templates" };
+25
View File
@@ -8,6 +8,12 @@ public class ManagementHttpClient : IDisposable
{
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="ManagementHttpClient"/> class.
/// </summary>
/// <param name="baseUrl">The base URL for the management API.</param>
/// <param name="username">The username for HTTP Basic authentication.</param>
/// <param name="password">The password for HTTP Basic authentication.</param>
public ManagementHttpClient(string baseUrl, string username, string password)
: this(new HttpClient(), baseUrl, username, password)
{
@@ -18,6 +24,10 @@ public class ManagementHttpClient : IDisposable
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
/// be exercised without a live server.
/// </summary>
/// <param name="httpClient">The HTTP client to use for requests.</param>
/// <param name="baseUrl">The base URL for the management API.</param>
/// <param name="username">The username for HTTP Basic authentication.</param>
/// <param name="password">The password for HTTP Basic authentication.</param>
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
{
_httpClient = httpClient;
@@ -27,6 +37,13 @@ public class ManagementHttpClient : IDisposable
new AuthenticationHeaderValue("Basic", credentials);
}
/// <summary>
/// Sends a management command to the management API.
/// </summary>
/// <param name="commandName">The command name to execute.</param>
/// <param name="payload">The command payload.</param>
/// <param name="timeout">The request timeout.</param>
/// <returns>A management response containing status and data.</returns>
public async Task<ManagementResponse> SendCommandAsync(string commandName, object payload, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
@@ -82,6 +99,8 @@ public class ManagementHttpClient : IDisposable
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
/// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
/// <param name="timeout">The request timeout.</param>
/// <returns>A management response containing status and data.</returns>
public async Task<ManagementResponse> SendGetAsync(string relativePath, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
@@ -130,9 +149,15 @@ public class ManagementHttpClient : IDisposable
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
/// option ensures the body is not pre-buffered.
/// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The raw HTTP response message for streaming.</returns>
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
/// <summary>
/// Disposes the underlying HTTP client.
/// </summary>
public void Dispose() => _httpClient.Dispose();
}
+8
View File
@@ -12,16 +12,24 @@ public static class OutputFormatter
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>Serializes <paramref name="data"/> to indented JSON and writes it to standard output.</summary>
/// <param name="data">The object to serialize; <c>null</c> is serialized as JSON <c>null</c>.</param>
public static void WriteJson(object? data)
{
Console.WriteLine(JsonSerializer.Serialize(data, JsonOptions));
}
/// <summary>Writes a JSON error envelope with the given message and code to standard error.</summary>
/// <param name="message">Human-readable error description.</param>
/// <param name="code">Machine-readable error code.</param>
public static void WriteError(string message, string code)
{
Console.Error.WriteLine(JsonSerializer.Serialize(new { error = message, code }, JsonOptions));
}
/// <summary>Writes a plain-text padded table to standard output with the given column headers and data rows.</summary>
/// <param name="rows">Data rows; each inner array corresponds to a column in the same order as <paramref name="headers"/>.</param>
/// <param name="headers">Column header labels.</param>
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
{
var allRows = new List<string[]> { headers };
@@ -43,6 +43,9 @@ public static class AuditExportEndpoints
/// </summary>
public const int DefaultMaxRows = 100_000;
/// <summary>Registers the audit log CSV export endpoint on the given route builder.</summary>
/// <param name="endpoints">The endpoint route builder to register against.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
@@ -56,6 +59,8 @@ public static class AuditExportEndpoints
/// tests can call it directly when desirable; the live wire-up goes
/// through the minimal-API map above.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <param name="exportService">The export service used to stream audit rows as CSV.</param>
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{
var filter = ParseFilter(context.Request.Query);
@@ -88,6 +93,7 @@ public static class AuditExportEndpoints
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks>
/// <param name="query">The query string parameters from the HTTP request.</param>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
@@ -15,6 +15,8 @@ namespace ScadaLink.CentralUI.Auth;
/// </summary>
public static class AuthEndpoints
{
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/auth/login", async (HttpContext context) =>
@@ -155,6 +157,7 @@ public static class AuthEndpoints
/// cookie session is still valid and <c>401</c> once it has lapsed
/// server-side. See CentralUI-020.
/// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
public static Task HandlePing(HttpContext context)
{
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
@@ -19,6 +19,7 @@ public static class ClaimsPrincipalExtensions
/// The audit username for <paramref name="principal"/>, or
/// <see cref="UnknownUser"/> when the claim is absent.
/// </summary>
/// <param name="principal">The claims principal to read the username from.</param>
public static string GetUsername(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser;
@@ -26,6 +27,7 @@ public static class ClaimsPrincipalExtensions
/// The display name for <paramref name="principal"/>, or <c>null</c> when
/// the claim is absent.
/// </summary>
/// <param name="principal">The claims principal to read the display name from.</param>
public static string? GetDisplayName(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
@@ -34,6 +36,7 @@ public static class ClaimsPrincipalExtensions
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
/// ten components (CentralUI-024).
/// </summary>
/// <param name="authStateProvider">The Blazor authentication state provider to read from.</param>
public static async Task<string> GetCurrentUsernameAsync(
this AuthenticationStateProvider authStateProvider)
{
@@ -28,6 +28,10 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
{
private readonly Task<AuthenticationState> _circuitAuthState;
/// <summary>
/// Snapshots the authenticated principal from the current HTTP context for use throughout the circuit lifetime.
/// </summary>
/// <param name="httpContextAccessor">Accessor used to read the initial HTTP context principal.</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
{
// Snapshot the principal at circuit-construction time. HttpContext is
@@ -38,6 +42,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
}
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _circuitAuthState;
}
@@ -27,6 +27,8 @@ public sealed class SiteScopeService
private readonly AuthenticationStateProvider _authStateProvider;
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
/// <summary>Initializes a new instance of <see cref="SiteScopeService"/>.</summary>
/// <param name="authStateProvider">The Blazor authentication state provider used to read the current user's claims.</param>
public SiteScopeService(AuthenticationStateProvider authStateProvider)
{
_authStateProvider = authStateProvider;
@@ -51,6 +53,7 @@ public sealed class SiteScopeService
/// Returns the subset of <paramref name="sites"/> the user is permitted to
/// see. A system-wide user gets the full list back unchanged.
/// </summary>
/// <param name="sites">The full set of sites to filter.</param>
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
{
var (isSystemWide, allowed) = await ResolveAsync();
@@ -63,6 +66,7 @@ public sealed class SiteScopeService
/// True when the user may operate on the site with the given <c>Site.Id</c>.
/// Must be re-checked server-side before any mutating cross-site command.
/// </summary>
/// <param name="siteId">The <c>Site.Id</c> to check.</param>
public async Task<bool> IsSiteAllowedAsync(int siteId)
{
var (isSystemWide, allowed) = await ResolveAsync();
@@ -63,6 +63,7 @@ public partial class AuditFilterBar
/// </summary>
[Parameter] public string? InitialInstanceSearch { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// One-shot prefill from a drill-in deep link. Subsequent parameter changes
@@ -33,9 +33,13 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// </summary>
public sealed class AuditQueryModel
{
/// <summary>Selected channel filter chips; empty means all channels.</summary>
public HashSet<AuditChannel> Channels { get; } = new();
/// <summary>Selected kind filter chips; empty means all kinds.</summary>
public HashSet<AuditKind> Kinds { get; } = new();
/// <summary>Selected status filter chips; empty means all statuses.</summary>
public HashSet<AuditStatus> Statuses { get; } = new();
/// <summary>Selected source-site identifier chips; empty means all sites.</summary>
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
@@ -46,13 +50,20 @@ public sealed class AuditQueryModel
/// </summary>
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Selected time-range preset controlling which historical window is queried.</summary>
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
/// <summary>Custom start of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomFromUtc { get; set; }
/// <summary>Custom end of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomToUtc { get; set; }
/// <summary>Free-text filter applied to instance names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string InstanceSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to script names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string ScriptSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the target field (external system / DB name / notification list).</summary>
public string TargetSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the actor field (instance or inbound API key name).</summary>
public string ActorSearch { get; set; } = string.Empty;
/// <summary>
@@ -72,6 +83,7 @@ public sealed class AuditQueryModel
/// </summary>
public string ParentExecutionId { get; set; } = string.Empty;
/// <summary>When true and no explicit status chips are selected, the filter targets the full non-success status set.</summary>
public bool ErrorsOnly { get; set; }
/// <summary>
@@ -133,6 +145,8 @@ public sealed class AuditQueryModel
/// multi-select maps straight through to its filter list (an empty set yields
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary>
/// <param name="utcNow">The current UTC timestamp used to compute relative time-range windows.</param>
/// <returns>A populated <see cref="AuditLogQueryFilter"/> ready for the repository.</returns>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var statuses = ResolveStatuses();
@@ -183,6 +183,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
? $"--audit-col-width: {width}px;"
: string.Empty;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
@@ -255,6 +256,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -366,6 +368,8 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header.
/// </summary>
/// <param name="columnKey">The stable key of the resized column.</param>
/// <param name="widthPx">The new column width in pixels.</param>
[JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx)
{
@@ -384,6 +388,8 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders.
/// </summary>
/// <param name="fromKey">The stable key of the column being dragged.</param>
/// <param name="toKey">The stable key of the target column drop slot.</param>
[JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey)
{
@@ -422,6 +428,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
}
}
/// <summary>
/// Releases the .NET object reference held for JS interop callbacks.
/// </summary>
public ValueTask DisposeAsync()
{
_selfRef?.Dispose();
@@ -80,6 +80,7 @@ public partial class ExecutionDetailModal
/// </summary>
private const int RowPageSize = 100;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Load only on the closed → open transition. A re-render while already
@@ -100,6 +100,7 @@ public partial class ExecutionTree
// the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new();
/// <inheritdoc />
protected override void OnParametersSet()
{
// Nested instance: the parent already assembled our subtrees.
@@ -54,6 +54,7 @@ public partial class AuditLogPage : IDisposable
private bool _drawerOpen;
private string? _initialInstanceSearch;
/// <inheritdoc />
protected override void OnInitialized()
{
ApplyQueryStringFilters();
@@ -81,6 +82,7 @@ public partial class AuditLogPage : IDisposable
});
}
/// <summary>Unsubscribes from navigation events to prevent memory leaks when the component is removed.</summary>
public void Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
@@ -248,6 +250,10 @@ public partial class AuditLogPage : IDisposable
/// </remarks>
internal string ExportUrl => BuildExportUrl(_currentFilter);
/// <summary>
/// Builds the CSV export URL for the given filter, encoding all active filter dimensions as query parameters.
/// </summary>
/// <param name="filter">Currently applied filter; null returns the bare export endpoint.</param>
internal static string BuildExportUrl(AuditLogQueryFilter? filter)
{
const string basePath = "/api/centralui/audit/export";
@@ -49,6 +49,7 @@ public partial class ExecutionTreePage
private Guid? _modalExecutionId;
private bool _modalOpen;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
_executionId = ParseExecutionId();
@@ -105,6 +105,7 @@ public partial class TransportExport : ComponentBase
private bool _downloadInProgress;
private string? _downloadError;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await LoadAllAsync();
@@ -181,6 +182,7 @@ public partial class TransportExport : ComponentBase
/// to colour an inline strength meter; never used to gate the export — the
/// importer enforces its own strength + lockout policies.
/// </summary>
/// <param name="s">The passphrase string to score.</param>
internal static int PassphraseStrength(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
@@ -259,6 +261,7 @@ public partial class TransportExport : ComponentBase
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
/// knows exactly what an unencrypted export would leak.
/// </summary>
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
internal static int CountSecrets(ResolvedExport resolved)
{
var count = 0;
@@ -363,6 +366,8 @@ public partial class TransportExport : ComponentBase
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
/// browser-rejected filenames.
/// </summary>
/// <param name="sourceEnvironment">The environment label to embed in the filename (sanitised to filename-safe characters).</param>
/// <param name="nowUtc">Timestamp to use for the datetime segment; defaults to <see cref="DateTimeOffset.UtcNow"/> when null.</param>
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
{
var safe = SanitizeForFilename(sourceEnvironment);
@@ -420,6 +425,10 @@ public partial class TransportExport : ComponentBase
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
/// the auto-included dependencies the resolver pulled in for the user.
/// </summary>
/// <typeparam name="T">The element type of the artifact list.</typeparam>
/// <param name="all">The full resolved list including both seed and auto-included items.</param>
/// <param name="seed">The set of explicitly selected item ids.</param>
/// <param name="idOf">Function that extracts the integer id from an item.</param>
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
{
return all.Where(x => !seed.Contains(idOf(x))).ToList();
@@ -353,6 +353,8 @@ public partial class TransportImport : ComponentBase
/// </list>
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
/// </summary>
/// <param name="preview">The import preview containing all conflict items to map.</param>
/// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
ImportPreview preview)
{
@@ -89,6 +89,7 @@ public partial class SiteCallsReport
private bool HasNextPage => _nextCursor is not null;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
try
@@ -31,6 +31,9 @@ internal static class AlarmTriggerConfigCodec
/// type. Returns a model with default values on null/empty/malformed input
/// or for missing keys — never throws.
/// </summary>
/// <param name="json">The trigger configuration JSON string, or null/empty for defaults.</param>
/// <param name="type">The alarm trigger type that determines which properties to extract.</param>
/// <returns>A populated AlarmTriggerModel with default values for missing fields.</returns>
internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type)
{
var model = new AlarmTriggerModel();
@@ -115,6 +118,9 @@ internal static class AlarmTriggerConfigCodec
/// current trigger type. <c>Expression</c> is not bound to a single
/// attribute, so <c>attributeName</c> is omitted for it.
/// </summary>
/// <param name="model">The AlarmTriggerModel to serialize.</param>
/// <param name="type">The alarm trigger type determining which properties to serialize.</param>
/// <returns>The serialized JSON representation of the model.</returns>
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
{
using var stream = new MemoryStream();
@@ -174,6 +180,11 @@ internal static class AlarmTriggerConfigCodec
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Normalizes a direction string to one of: rising, falling, or either.
/// </summary>
/// <param name="raw">The raw direction string to normalize.</param>
/// <returns>Normalized direction: "rising", "falling", or "either".</returns>
internal static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => "rising",
@@ -213,47 +224,122 @@ internal static class AlarmTriggerConfigCodec
internal sealed class AlarmTriggerModel
{
/// <summary>
/// The attribute name bound to this trigger.
/// </summary>
public string? AttributeName { get; set; }
// ValueMatch
/// <summary>
/// The value to match against the attribute for ValueMatch triggers.
/// </summary>
public string? MatchValue { get; set; }
/// <summary>
/// Indicates whether the match should be inverted (not equal) for ValueMatch triggers.
/// </summary>
public bool NotEquals { get; set; }
// RangeViolation
/// <summary>
/// The minimum threshold for RangeViolation triggers.
/// </summary>
public double? Min { get; set; }
/// <summary>
/// The maximum threshold for RangeViolation triggers.
/// </summary>
public double? Max { get; set; }
// RateOfChange
/// <summary>
/// The threshold per second for RateOfChange triggers.
/// </summary>
public double? ThresholdPerSecond { get; set; }
/// <summary>
/// The time window in seconds for RateOfChange rate calculation.
/// </summary>
public double? WindowSeconds { get; set; }
/// <summary>
/// The direction of change: "rising", "falling", or "either" for RateOfChange triggers.
/// </summary>
public string Direction { get; set; } = "either";
// HiLo — any subset of setpoints may be set; per-setpoint priorities
// override the alarm-level priority for that band.
/// <summary>
/// The low-low setpoint for HiLo triggers.
/// </summary>
public double? LoLo { get; set; }
/// <summary>
/// The low setpoint for HiLo triggers.
/// </summary>
public double? Lo { get; set; }
/// <summary>
/// The high setpoint for HiLo triggers.
/// </summary>
public double? Hi { get; set; }
/// <summary>
/// The high-high setpoint for HiLo triggers.
/// </summary>
public double? HiHi { get; set; }
/// <summary>
/// The priority for low-low alarm state.
/// </summary>
public int? LoLoPriority { get; set; }
/// <summary>
/// The priority for low alarm state.
/// </summary>
public int? LoPriority { get; set; }
/// <summary>
/// The priority for high alarm state.
/// </summary>
public int? HiPriority { get; set; }
/// <summary>
/// The priority for high-high alarm state.
/// </summary>
public int? HiHiPriority { get; set; }
// Hysteresis: optional deactivation deadband per setpoint. Once at the
// band, the setpoint threshold is relaxed by this amount before the alarm
// de-escalates. Prevents flapping when the value hovers at the boundary.
/// <summary>
/// The deadband for low-low alarm de-escalation.
/// </summary>
public double? LoLoDeadband { get; set; }
/// <summary>
/// The deadband for low alarm de-escalation.
/// </summary>
public double? LoDeadband { get; set; }
/// <summary>
/// The deadband for high alarm de-escalation.
/// </summary>
public double? HiDeadband { get; set; }
/// <summary>
/// The deadband for high-high alarm de-escalation.
/// </summary>
public double? HiHiDeadband { get; set; }
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
// and may be used by notification routing or operator displays.
/// <summary>
/// The operator message for low-low alarm state.
/// </summary>
public string? LoLoMessage { get; set; }
/// <summary>
/// The operator message for low alarm state.
/// </summary>
public string? LoMessage { get; set; }
/// <summary>
/// The operator message for high alarm state.
/// </summary>
public string? HiMessage { get; set; }
/// <summary>
/// The operator message for high-high alarm state.
/// </summary>
public string? HiHiMessage { get; set; }
// Expression — boolean C# expression evaluated on attribute updates.
/// <summary>
/// The boolean C# expression to evaluate for Expression triggers.
/// </summary>
public string? Expression { get; set; }
}
@@ -32,6 +32,7 @@ public class DialogService : IDialogService
// (the Blazor renderer's, for an event-handler caller).
private TaskCompletionSource<object?>? _tcs;
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
{
EnsureNoActiveDialog();
@@ -42,6 +43,7 @@ public class DialogService : IDialogService
return Project(tcs.Task, static r => r is bool b && b);
}
/// <inheritdoc />
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
{
EnsureNoActiveDialog();
@@ -68,6 +70,7 @@ public class DialogService : IDialogService
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
/// and a <c>string?</c> for prompts (null = cancel).
/// </summary>
/// <param name="result">The user's response: a <c>bool</c> for confirms or a <c>string?</c> for prompts.</param>
internal void Resolve(object? result)
{
var tcs = _tcs;
@@ -17,6 +17,7 @@ internal static class DurationInput
/// A null or non-positive duration yields a blank value and the default
/// <c>sec</c> unit.
/// </summary>
/// <param name="duration">The duration to split, or null for unset.</param>
internal static (string? Value, string Unit) Split(TimeSpan? duration)
{
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
@@ -31,6 +32,8 @@ internal static class DurationInput
/// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset).
/// </summary>
/// <param name="value">The numeric string entered by the user.</param>
/// <param name="unit">The selected unit token (ms, sec, or min).</param>
internal static TimeSpan? Compose(string? value, string unit)
{
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
@@ -15,6 +15,10 @@ public static class PagerWindow
/// <paramref name="radius"/> is how many pages to show on each side of the
/// current page.
/// </summary>
/// <param name="currentPage">The currently active page (1-based).</param>
/// <param name="totalPages">The total number of pages.</param>
/// <param name="radius">Number of page buttons to show on each side of the current page; default 2.</param>
/// <returns>An ordered list of page numbers and <c>0</c> ellipsis placeholders.</returns>
public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
{
if (totalPages <= 1)
@@ -24,8 +24,11 @@ internal sealed class SchemaProperty
{
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid();
/// <summary>Property name as it appears in the JSON Schema <c>properties</c> map.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>When true, the property is listed in the JSON Schema <c>required</c> array.</summary>
public bool Required { get; set; } = true;
/// <summary>The JSON Schema node describing this property's type and structure.</summary>
public SchemaNode Schema { get; set; } = new();
}
@@ -41,6 +44,8 @@ internal static class SchemaBuilderModel
/// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema.
/// </summary>
/// <param name="json">JSON Schema string to parse, or null/empty to return the fallback.</param>
/// <param name="fallback">The <see cref="SchemaNode"/> to return when the input cannot be parsed.</param>
public static SchemaNode Parse(string? json, SchemaNode fallback)
{
if (string.IsNullOrWhiteSpace(json)) return fallback;
@@ -66,6 +71,10 @@ internal static class SchemaBuilderModel
/// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" };
/// <summary>
/// Serializes a <see cref="SchemaNode"/> tree to its canonical JSON Schema string.
/// </summary>
/// <param name="node">The schema node to serialize.</param>
public static string Serialize(SchemaNode node)
{
using var stream = new System.IO.MemoryStream();
@@ -9,12 +9,20 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// </summary>
public static class ScriptParameterNames
{
/// <summary>
/// Parses a parameter definitions JSON Schema and returns the declared parameter names.
/// </summary>
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
public static IReadOnlyList<string> Parse(string? json) =>
JsonSchemaShapeParser.ParseParameters(json)
.Select(p => p.Name)
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
/// <summary>
/// Parses a parameter definitions JSON Schema and returns the full parameter shape objects.
/// </summary>
/// <param name="json">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
JsonSchemaShapeParser.ParseParameters(json);
}
@@ -67,6 +67,7 @@ internal static class ScriptTriggerConfigCodec
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary>
/// <param name="triggerType">The raw trigger type string from the template script entity.</param>
internal static ScriptTriggerKind ParseKind(string? triggerType)
{
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None;
@@ -87,12 +88,14 @@ internal static class ScriptTriggerConfigCodec
/// Expression. False for Interval (its own period is the cadence), Call
/// (invoked explicitly, never throttled), and None/Unknown.
/// </summary>
/// <param name="triggerType">The raw trigger type string to classify.</param>
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional
or ScriptTriggerKind.Expression;
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
/// <param name="kind">The trigger kind to convert.</param>
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{
ScriptTriggerKind.Interval => "Interval",
@@ -108,6 +111,8 @@ internal static class ScriptTriggerConfigCodec
/// Returns a model with default values on null/empty/malformed input or for
/// missing keys — never throws.
/// </summary>
/// <param name="json">The raw JSON trigger configuration string.</param>
/// <param name="kind">The trigger kind, used to determine which fields to parse.</param>
internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
{
var model = new ScriptTriggerModel();
@@ -154,6 +159,8 @@ internal static class ScriptTriggerConfigCodec
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
/// expects. Returns null for None/Unknown (no structured config to emit).
/// </summary>
/// <param name="model">The trigger model to serialize.</param>
/// <param name="kind">The trigger kind, used to determine which fields to emit.</param>
internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
{
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null;
@@ -206,6 +213,7 @@ internal static class ScriptTriggerConfigCodec
}
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else "&gt;".</summary>
/// <param name="raw">The raw operator string to normalize.</param>
internal static string NormalizeOperator(string? raw)
{
var op = raw?.Trim();
@@ -22,9 +22,13 @@ public enum TemplateTreeNodeKind
/// </summary>
public sealed class TemplateTreeNode
{
/// <summary>Discriminator indicating whether this node represents a folder, template, or composition slot.</summary>
public required TemplateTreeNodeKind Kind { get; init; }
/// <summary>Database id of the underlying folder, template, or composition record.</summary>
public required int Id { get; init; }
/// <summary>Display name of the node.</summary>
public required string Name { get; init; }
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
public List<TemplateTreeNode> Children { get; } = new();
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
@@ -18,6 +18,7 @@ namespace ScadaLink.CentralUI.Components.Shared;
public static class TriggerAttributeMapper
{
/// <summary>Direct and inherited attributes, exposed as <c>Attributes["..."]</c>.</summary>
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
public static IReadOnlyList<AttributeShape> SelfAttributes(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
@@ -30,6 +31,7 @@ public static class TriggerAttributeMapper
/// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix
/// are skipped (no child scope to attach them to).
/// </summary>
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
public static IReadOnlyList<CompositionContext> Children(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
@@ -13,6 +13,8 @@ public static class EndpointExtensions
/// Maps the Central UI endpoints. The caller must provide the root App component type
/// from the Host assembly so that blazor.web.js is served correctly.
/// </summary>
/// <typeparam name="TApp">The root Blazor App component type, supplied by the Host assembly.</typeparam>
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
public static IEndpointRouteBuilder MapCentralUI<TApp>(this IEndpointRouteBuilder endpoints)
where TApp : Microsoft.AspNetCore.Components.IComponent
{
@@ -8,6 +8,7 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public interface ISharedScriptCatalog
{
/// <summary>Returns the parameter and return shapes for all registered shared scripts.</summary>
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
/// <summary>
@@ -15,6 +16,8 @@ public interface ISharedScriptCatalog
/// null if no shared script with that name exists. Used by Test Run to
/// compile and execute nested CallShared invocations.
/// </summary>
/// <param name="name">Name of the shared script to retrieve.</param>
/// <param name="cancellationToken">Cancellation token for the async lookup.</param>
Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
}
@@ -24,8 +27,13 @@ public class SharedScriptCatalog : ISharedScriptCatalog
{
private readonly SharedScriptService _service;
/// <summary>
/// Initializes a new <see cref="SharedScriptCatalog"/> backed by the given service.
/// </summary>
/// <param name="service">Service providing access to shared script definitions.</param>
public SharedScriptCatalog(SharedScriptService service) => _service = service;
/// <inheritdoc />
public async Task<IReadOnlyList<ScriptShape>> GetShapesAsync()
{
var scripts = await _service.GetAllSharedScriptsAsync();
@@ -34,6 +42,7 @@ public class SharedScriptCatalog : ISharedScriptCatalog
.ToList();
}
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name)) return null;
@@ -10,44 +10,84 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public class InboundScriptHost
{
/// <summary>
/// The request parameters passed to the inbound API method.
/// </summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// The route helper for accessing target instances.
/// </summary>
public RouteHelper Route { get; } = new();
/// <summary>
/// The cancellation token for the operation.
/// </summary>
public System.Threading.CancellationToken CancellationToken { get; }
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteHelper
{
/// <summary>
/// Targets a specific instance for method invocation.
/// </summary>
/// <param name="instanceCode">The instance code to target.</param>
public RouteTarget To(string instanceCode) => new();
}
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary>
public class RouteTarget
{
/// <summary>
/// Calls a script on the target instance.
/// </summary>
/// <param name="scriptName">The name of the script to call.</param>
/// <param name="parameters">Optional parameters to pass to the script.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<object?> Call(
string scriptName,
object? parameters = null,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
/// <summary>
/// Gets an attribute value from the target instance.
/// </summary>
/// <param name="attributeName">The name of the attribute to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<object?> GetAttribute(
string attributeName,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
/// <summary>
/// Gets multiple attribute values from the target instance.
/// </summary>
/// <param name="attributeNames">The names of the attributes to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>(
new Dictionary<string, object?>());
/// <summary>
/// Sets a single attribute value on the target instance.
/// </summary>
/// <param name="attributeName">The name of the attribute to set.</param>
/// <param name="value">The value to set.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task SetAttribute(
string attributeName,
string value,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Sets multiple attribute values on the target instance.
/// </summary>
/// <param name="attributeValues">Dictionary of attribute names to values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
System.Threading.CancellationToken cancellationToken = default) =>
@@ -18,6 +18,8 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public static class JsonSchemaShapeParser
{
/// <summary>Parses a JSON Schema or legacy flat-array parameters definition and returns the resulting parameter shapes.</summary>
/// <param name="json">The JSON string to parse; <c>null</c> or whitespace returns an empty list.</param>
public static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
@@ -37,6 +39,8 @@ public static class JsonSchemaShapeParser
}
}
/// <summary>Parses a JSON Schema or legacy return-type definition and returns the normalised type name, or <c>null</c> if absent or unrecognised.</summary>
/// <param name="json">The JSON string to parse; <c>null</c> or whitespace returns <c>null</c>.</param>
public static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
@@ -33,6 +33,7 @@ internal sealed class SandboxConsoleCapture : TextWriter
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
/// <inheritdoc />
public override Encoding Encoding => _fallback.Encoding;
/// <summary>
@@ -70,6 +71,7 @@ internal sealed class SandboxConsoleCapture : TextWriter
/// The scope is restored on dispose, so nesting and concurrent scopes on
/// other call-trees are unaffected.
/// </summary>
/// <param name="buffer">The writer that receives console output for this scope.</param>
public CaptureScope BeginCapture(StringWriter buffer)
{
var previous = _current.Value;
@@ -77,15 +79,20 @@ internal sealed class SandboxConsoleCapture : TextWriter
return new CaptureScope(this, previous);
}
/// <inheritdoc />
public override void Write(char value) => Target.Write(value);
/// <inheritdoc />
public override void Write(string? value) => Target.Write(value);
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) =>
Target.Write(buffer, index, count);
/// <inheritdoc />
public override void WriteLine() => Target.WriteLine();
/// <inheritdoc />
public override void WriteLine(string? value) => Target.WriteLine(value);
private TextWriter Target => _current.Value ?? _fallback;
@@ -95,12 +102,18 @@ internal sealed class SandboxConsoleCapture : TextWriter
private readonly SandboxConsoleCapture _owner;
private readonly StringWriter? _previous;
/// <summary>
/// Initializes a capture scope that restores the previous writer on dispose.
/// </summary>
/// <param name="owner">The owning <see cref="SandboxConsoleCapture"/> instance.</param>
/// <param name="previous">The writer that was active before this scope was opened.</param>
internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
{
_owner = owner;
_previous = previous;
}
/// <summary>Restores the previous capture scope.</summary>
public void Dispose() => _owner._current.Value = _previous;
}
}
@@ -18,12 +18,20 @@ public class SandboxExternalHelper
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
/// <summary>Initializes a new instance of the SandboxExternalHelper class.</summary>
/// <param name="client">Optional external system client for test runs.</param>
/// <param name="instanceName">The instance name context.</param>
public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
{
_client = client;
_instanceName = instanceName;
}
/// <summary>Invokes a synchronous external system call.</summary>
/// <param name="systemName">The external system name.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="parameters">Optional method parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ExternalCallResult> Call(
string systemName,
string methodName,
@@ -36,6 +44,11 @@ public class SandboxExternalHelper
return _client.CallAsync(systemName, methodName, parameters, cancellationToken);
}
/// <summary>Invokes a cached external system call.</summary>
/// <param name="systemName">The external system name.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="parameters">Optional method parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ExternalCallResult> CachedCall(
string systemName,
string methodName,
@@ -49,17 +62,24 @@ public class SandboxExternalHelper
}
}
/// <summary>Sandbox database helper for script analysis.</summary>
public class SandboxDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
/// <summary>Initializes a new instance of the SandboxDatabaseHelper class.</summary>
/// <param name="gateway">Optional database gateway for test runs.</param>
/// <param name="instanceName">The instance name context.</param>
public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName)
{
_gateway = gateway;
_instanceName = instanceName;
}
/// <summary>Gets a database connection by name.</summary>
/// <param name="name">The database connection name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<DbConnection> Connection(string name, CancellationToken cancellationToken = default)
{
if (_gateway == null)
@@ -68,6 +88,11 @@ public class SandboxDatabaseHelper
return _gateway.GetConnectionAsync(name, cancellationToken);
}
/// <summary>Executes a cached database write operation.</summary>
/// <param name="name">The database connection name.</param>
/// <param name="sql">The SQL statement to execute.</param>
/// <param name="parameters">Optional SQL parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task CachedWrite(
string name,
string sql,
@@ -97,6 +122,7 @@ public class SandboxDatabaseHelper
public class SandboxNotifyHelper
{
/// <summary>Selects the notification list to send to.</summary>
/// <param name="listName">The notification list name.</param>
public SandboxNotifyTarget To(string listName) =>
new();
@@ -106,6 +132,7 @@ public class SandboxNotifyHelper
/// <c>Unknown</c> status — it exists for signature fidelity with
/// <c>NotifyHelper.Status</c>.
/// </summary>
/// <param name="notificationId">The notification ID to check status for.</param>
public Task<NotificationDeliveryStatus> Status(string notificationId) =>
Task.FromResult(new NotificationDeliveryStatus("Unknown", 0, null, null));
}
@@ -116,6 +143,7 @@ public class SandboxNotifyHelper
/// </summary>
public class SandboxNotifyTarget
{
/// <summary>Initializes a new instance of the SandboxNotifyTarget class.</summary>
internal SandboxNotifyTarget()
{
}
@@ -125,6 +153,9 @@ public class SandboxNotifyTarget
/// the sandbox nothing is enqueued or delivered; a fake id is returned so
/// the call type-checks identically to production.
/// </summary>
/// <param name="subject">The notification subject.</param>
/// <param name="message">The notification message.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<string> Send(string subject, string message, CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N"));
}
@@ -14,15 +14,22 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public class SandboxInboundScriptHost
{
/// <summary>Gets or initializes the script input parameters.</summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>Gets or initializes the cancellation token for the test run.</summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>Gets the route accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
public RouteAccessor Route { get; } = new();
/// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteAccessor
{
/// <summary>
/// Creates a sandbox route target that throws on every operation.
/// </summary>
/// <param name="instanceCode">The instance code (used only in the exception message).</param>
public RouteTarget To(string instanceCode) => new(instanceCode);
}
@@ -31,30 +38,61 @@ public class SandboxInboundScriptHost
{
private readonly string _instanceCode;
/// <summary>
/// Initializes the sandbox route target for the given instance code.
/// </summary>
/// <param name="instanceCode">The instance code referenced by the routing expression (used in the exception message).</param>
internal RouteTarget(string instanceCode) => _instanceCode = instanceCode;
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="scriptName">Script name (included in the exception message).</param>
/// <param name="parameters">Unused parameters.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<object?> Call(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default) =>
throw Unavailable($"Call(\"{scriptName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeName">Attribute name (included in the exception message).</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<object?> GetAttribute(
string attributeName,
CancellationToken cancellationToken = default) =>
throw Unavailable($"GetAttribute(\"{attributeName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeNames">Attribute names (unused).</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default) =>
throw Unavailable("GetAttributes(...)");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeName">Attribute name (included in the exception message).</param>
/// <param name="value">Unused value.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task SetAttribute(
string attributeName,
string value,
CancellationToken cancellationToken = default) =>
throw Unavailable($"SetAttribute(\"{attributeName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeValues">Unused attribute values.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default) =>
@@ -17,6 +17,13 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
private readonly string _instanceUniqueName;
private readonly CancellationToken _runToken;
/// <summary>
/// Initializes a new <see cref="SandboxInstanceGateway"/> bound to a specific deployed instance.
/// </summary>
/// <param name="comms">Communication service used to route requests to the site.</param>
/// <param name="siteId">String identifier of the site hosting the bound instance.</param>
/// <param name="instanceUniqueName">Unique name of the instance to route calls to.</param>
/// <param name="runToken">Cancellation token for the test run; applied to all cross-site calls.</param>
public SandboxInstanceGateway(
CommunicationService comms,
string siteId,
@@ -29,6 +36,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
_runToken = runToken;
}
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
{
var request = new RouteToGetAttributesRequest(
@@ -41,6 +49,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
return response.Values.TryGetValue(canonicalName, out var value) ? value : null;
}
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct)
{
var request = new RouteToSetAttributesRequest(
@@ -52,6 +61,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
$"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
}
/// <inheritdoc />
public async Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
{
@@ -19,23 +19,64 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public class SandboxScriptHost
{
/// <summary>
/// Script parameters passed to the sandbox.
/// </summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// Cancellation token for the sandbox execution.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Alarm context for the sandbox.
/// </summary>
public AlarmContext? Alarm { get; init; }
/// <summary>
/// Script scope defining the execution context.
/// </summary>
public ScriptScope Scope { get; init; } = ScriptScope.Root;
/// <summary>
/// Instance context providing access to deployed instance data.
/// </summary>
public SandboxInstanceContext Instance { get; init; } = new();
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database => Instance.Database;
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify => Instance.Notify;
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Accessor for attributes scoped to the current instance.
/// </summary>
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for child compositions.
/// </summary>
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for the parent composition, or null if at root.
/// </summary>
public SandboxCompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath);
}
@@ -47,8 +88,29 @@ public class SandboxScriptHost
/// </summary>
public interface ISandboxInstanceGateway
{
/// <summary>
/// Gets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attribute value, or null if not found.</returns>
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
/// <summary>
/// Sets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="value">The value to set.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
/// <summary>
/// Calls a script with the specified canonical name.
/// </summary>
/// <param name="canonicalScriptName">The canonical name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
}
@@ -65,11 +127,34 @@ public class SandboxInstanceContext
{
private readonly ISandboxInstanceGateway? _gateway;
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem { get; }
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database { get; }
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify { get; }
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts { get; }
/// <summary>
/// Initializes a new instance of the SandboxInstanceContext.
/// </summary>
/// <param name="gateway">Gateway for accessing deployed instance data, or null if unbound.</param>
/// <param name="external">External system helper, or null to create a default.</param>
/// <param name="database">Database helper, or null to create a default.</param>
/// <param name="notify">Notification helper, or null to create a default.</param>
/// <param name="scripts">Script call helper, or null to create a default.</param>
public SandboxInstanceContext(
ISandboxInstanceGateway? gateway = null,
SandboxExternalHelper? external = null,
@@ -84,6 +169,11 @@ public class SandboxInstanceContext
Scripts = scripts ?? new SandboxScriptCallHelper(null);
}
/// <summary>
/// Gets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAttribute(string attributeName)
{
if (_gateway == null)
@@ -93,6 +183,11 @@ public class SandboxInstanceContext
return _gateway.GetAttributeAsync(attributeName, CancellationToken.None);
}
/// <summary>
/// Sets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <param name="value">The value to set.</param>
public void SetAttribute(string attributeName, string value)
{
if (_gateway == null)
@@ -102,6 +197,12 @@ public class SandboxInstanceContext
_gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult();
}
/// <summary>
/// Calls a sibling script.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
{
if (_gateway == null)
@@ -121,12 +222,23 @@ public class SandboxScriptCallHelper
{
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared;
/// <summary>
/// Initializes a new instance of the SandboxScriptCallHelper.
/// </summary>
/// <param name="callShared">Delegate for calling shared scripts, or null if not available.</param>
public SandboxScriptCallHelper(
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
{
_callShared = callShared;
}
/// <summary>
/// Calls a shared script.
/// </summary>
/// <param name="scriptName">The name of the shared script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallShared(
string scriptName,
object? parameters = null,
@@ -147,25 +259,54 @@ public class SandboxAttributeAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The scope prefix for attribute resolution.
/// </summary>
public string ScopePrefix { get; }
/// <summary>
/// Initializes a new instance of the SandboxAttributeAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="prefix">The scope prefix for attribute names.</param>
public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
{
_ctx = ctx;
ScopePrefix = prefix;
}
/// <summary>
/// Resolves a key to its fully qualified name within the current scope.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The fully qualified attribute name.</returns>
public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
/// <summary>
/// Gets or sets an attribute value by key.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public object? this[string key]
{
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
}
/// <summary>
/// Gets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
/// <summary>
/// Sets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <param name="value">The value to set, or null.</param>
/// <returns>A task representing the operation.</returns>
public Task SetAsync(string key, object? value)
{
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
@@ -181,10 +322,21 @@ public class SandboxCompositionAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The path to the composition within the instance hierarchy.
/// </summary>
public string Path { get; }
/// <summary>
/// Accessor for attributes within the composition.
/// </summary>
public SandboxAttributeAccessor Attributes { get; }
/// <summary>
/// Initializes a new instance of the SandboxCompositionAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="path">The path to the composition within the instance hierarchy.</param>
public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
{
_ctx = ctx;
@@ -192,9 +344,20 @@ public class SandboxCompositionAccessor
Attributes = new SandboxAttributeAccessor(ctx, path);
}
/// <summary>
/// Resolves a script name to its fully qualified name within the composition.
/// </summary>
/// <param name="scriptName">The script name.</param>
/// <returns>The fully qualified script name.</returns>
public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + scriptName;
/// <summary>
/// Calls a script within the composition.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
}
@@ -208,12 +371,22 @@ public class SandboxChildrenAccessor
private readonly SandboxInstanceContext _ctx;
private readonly string _selfPath;
/// <summary>
/// Initializes a new instance of the SandboxChildrenAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="selfPath">The path to the parent composition.</param>
public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
{
_ctx = ctx;
_selfPath = selfPath;
}
/// <summary>
/// Gets a child composition by name.
/// </summary>
/// <param name="compositionName">The name of the child composition.</param>
/// <returns>An accessor for the child composition.</returns>
public SandboxCompositionAccessor this[string compositionName]
{
get
@@ -232,5 +405,9 @@ public class SandboxChildrenAccessor
/// </summary>
public class ScriptSandboxException : Exception
{
/// <summary>
/// Initializes a new instance of the ScriptSandboxException.
/// </summary>
/// <param name="message">The exception message.</param>
public ScriptSandboxException(string message) : base(message) { }
}
@@ -6,8 +6,14 @@ using ScadaLink.Security;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Minimal-API endpoint group for Roslyn-backed script analysis (diagnostics, completions, hover, etc.).
/// </summary>
public static class ScriptAnalysisEndpoints
{
/// <summary>Registers all script analysis endpoints under <c>/api/script-analysis</c>.</summary>
/// <param name="endpoints">The endpoint route builder to register against.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/script-analysis")
@@ -72,6 +72,10 @@ public class ScriptAnalysisService
private readonly IMemoryCache _cache;
private readonly IServiceProvider _services;
/// <summary>Initializes the service with its collaborators.</summary>
/// <param name="sharedScripts">Catalog of registered shared scripts for name completion and sandboxed execution.</param>
/// <param name="cache">Memory cache used to short-circuit repeated diagnostics for identical code.</param>
/// <param name="services">Service provider for resolving optional runtime collaborators (locator, comms, etc.).</param>
public ScriptAnalysisService(
ISharedScriptCatalog sharedScripts,
IMemoryCache cache,
@@ -97,6 +101,9 @@ public class ScriptAnalysisService
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
: compilation;
/// <summary>Compiles the script and returns editor diagnostic markers, including forbidden-API and SCADA-specific diagnostics.</summary>
/// <param name="request">The diagnose request containing the script code and context metadata.</param>
/// <returns>A <see cref="DiagnoseResponse"/> containing zero or more diagnostic markers.</returns>
public DiagnoseResponse Diagnose(DiagnoseRequest request)
{
if (string.IsNullOrEmpty(request.Code))
@@ -171,6 +178,9 @@ public class ScriptAnalysisService
/// land in the result without mutating process-global Console state — two
/// concurrent Test Runs do not interfere with each other.
/// </summary>
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the caller; combined with an internal timeout CTS.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
@@ -519,6 +529,9 @@ public class ScriptAnalysisService
return Convert.ToHexString(bytes);
}
/// <summary>Returns Roslyn-backed IntelliSense completions augmented with SCADA-specific string-literal suggestions.</summary>
/// <param name="request">The completions request containing code text, cursor position, and context metadata.</param>
/// <returns>A <see cref="CompletionsResponse"/> with the list of completion items.</returns>
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
{
if (string.IsNullOrEmpty(request.CodeText))
@@ -696,6 +709,9 @@ public class ScriptAnalysisService
InsertTextRules: insertAsSnippet);
}
/// <summary>Formats the script code using Roslyn's NormalizeWhitespace.</summary>
/// <param name="request">The format request containing the script source.</param>
/// <returns>A <see cref="FormatResponse"/> with the formatted code, or the original code if parsing fails.</returns>
public FormatResponse Format(FormatRequest request)
{
if (string.IsNullOrEmpty(request.Code))
@@ -723,9 +739,14 @@ public class ScriptAnalysisService
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
/// already self-labelling — there are no positional arguments to annotate.
/// </summary>
/// <param name="request">The inlay hints request (unused; always returns an empty set).</param>
/// <returns>An empty <see cref="InlayHintsResponse"/>.</returns>
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>());
/// <summary>Returns hover documentation for the symbol under the cursor, with SCADA-specific script-shape detail.</summary>
/// <param name="request">The hover request containing code text and cursor position.</param>
/// <returns>A <see cref="HoverResponse"/> with Markdown documentation, or a null-content response when no hover applies.</returns>
public async Task<HoverResponse> Hover(HoverRequest request)
{
var script = TryParse(request.CodeText);
@@ -772,6 +793,9 @@ public class ScriptAnalysisService
return new HoverResponse(FormatHover(shape, call));
}
/// <summary>Returns signature-help information for the script call at the cursor, resolving parameter shapes from the script catalog.</summary>
/// <param name="request">The signature-help request containing code text and cursor position.</param>
/// <returns>A <see cref="SignatureHelpResponse"/> with the active signature and parameter, or an empty response when none applies.</returns>
public async Task<SignatureHelpResponse> SignatureHelp(SignatureHelpRequest request)
{
var empty = new SignatureHelpResponse(null, null, 0);
@@ -8,6 +8,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary>
public static class ScriptShapeParser
{
/// <summary>Parses the parameter and return-type JSON schemas for a script and returns a <see cref="ScriptShape"/> describing its signature.</summary>
/// <param name="name">The canonical script name.</param>
/// <param name="parametersJson">The JSON Schema or legacy flat-array parameters definition, or <c>null</c> for parameterless scripts.</param>
/// <param name="returnJson">The JSON Schema or legacy return-type definition, or <c>null</c> for void scripts.</param>
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
{
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
@@ -10,6 +10,10 @@ namespace ScadaLink.CentralUI;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers all Central UI services including Blazor, auth state, dialogs, audit query, and script analysis.
/// </summary>
/// <param name="services">The service collection to configure.</param>
public static IServiceCollection AddCentralUI(this IServiceCollection services)
{
services.AddRazorComponents()
@@ -59,6 +59,10 @@ public sealed class AuditLogExportService : IAuditLogExportService
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));
@@ -170,6 +174,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
/// 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);
@@ -65,6 +65,8 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// <c>ScadaLinkDbContext</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)
@@ -77,6 +79,8 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// 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)
@@ -85,8 +89,10 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
/// <inheritdoc />
public int DefaultPageSize => 100;
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
@@ -21,6 +21,9 @@ public interface IAuditLogQueryService
/// <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,
@@ -49,6 +52,7 @@ public interface IAuditLogQueryService
/// 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>
@@ -66,6 +70,8 @@ public interface IAuditLogQueryService
/// implementation opens its own DI scope per call so the tree page's
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</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);
@@ -79,5 +85,6 @@ public interface IAuditLogQueryService
/// (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);
}
@@ -18,6 +18,11 @@ public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
"keep-oldest"
};
/// <summary>
/// Validates the cluster options, returning a failure result if any critical settings are misconfigured.
/// </summary>
/// <param name="name">Named options instance name (unused; all instances are validated identically).</param>
/// <param name="options">The cluster options to validate.</param>
public ValidateOptionsResult Validate(string? name, ClusterOptions options)
{
var failures = new List<string>();
@@ -22,6 +22,7 @@ public static class ServiceCollectionExtensions
/// into a broken cluster.
/// </para>
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services)
{
services.TryAddEnumerable(
@@ -37,6 +38,7 @@ public static class ServiceCollectionExtensions
/// fast with a clear cause instead of failing later, far from here.
/// </summary>
/// <exception cref="NotImplementedException">Always thrown.</exception>
/// <param name="services">The service collection (unused; method always throws).</param>
public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services)
{
throw new NotImplementedException(
@@ -2,16 +2,33 @@ namespace ScadaLink.Commons.Entities.Audit;
public class AuditLogEntry
{
/// <summary>Auto-incremented primary key.</summary>
public int Id { get; set; }
/// <summary>Username of the actor who performed the action.</summary>
public string User { get; set; }
/// <summary>Action performed (e.g. Created, Updated, Deleted).</summary>
public string Action { get; set; }
/// <summary>Entity type name (e.g. Template, ExternalSystem).</summary>
public string EntityType { get; set; }
/// <summary>String representation of the entity's primary key.</summary>
public string EntityId { get; set; }
/// <summary>Human-readable name of the affected entity.</summary>
public string EntityName { get; set; }
/// <summary>JSON snapshot of the entity's state after the action; null for deletes.</summary>
public string? AfterStateJson { get; set; }
/// <summary>UTC timestamp when the audit entry was recorded.</summary>
public DateTimeOffset Timestamp { get; set; }
/// <summary>Bundle import session id when this entry was created during a bundle import; otherwise null.</summary>
public Guid? BundleImportId { get; set; }
/// <summary>
/// Creates an audit log entry for the specified user action on a named entity.
/// </summary>
/// <param name="user">Username of the actor performing the action.</param>
/// <param name="action">Action name (e.g. Created, Updated, Deleted).</param>
/// <param name="entityType">Entity type name.</param>
/// <param name="entityId">String primary key of the affected entity.</param>
/// <param name="entityName">Human-readable name of the affected entity.</param>
public AuditLogEntry(string user, string action, string entityType, string entityId, string entityName)
{
User = user ?? throw new ArgumentNullException(nameof(user));
@@ -6,9 +6,13 @@ namespace ScadaLink.Commons.Entities.Deployment;
/// </summary>
public class DeployedConfigSnapshot
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Foreign key to the owning <c>Instance</c> entity.</summary>
public int InstanceId { get; set; }
/// <summary>Unique deployment identifier assigned at deploy time for idempotency.</summary>
public string DeploymentId { get; set; }
/// <summary>Revision hash of the flattened configuration at deploy time, used for staleness detection.</summary>
public string RevisionHash { get; set; }
/// <summary>
@@ -16,8 +20,13 @@ public class DeployedConfigSnapshot
/// </summary>
public string ConfigurationJson { get; set; }
/// <summary>UTC timestamp when this snapshot was persisted.</summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>Initializes a new snapshot with the deployment identity, revision hash, and serialized configuration.</summary>
/// <param name="deploymentId">Unique deployment identifier.</param>
/// <param name="revisionHash">Revision hash of the flattened configuration.</param>
/// <param name="configurationJson">JSON-serialized flattened configuration.</param>
public DeployedConfigSnapshot(string deploymentId, string revisionHash, string configurationJson)
{
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
@@ -4,14 +4,49 @@ namespace ScadaLink.Commons.Entities.Deployment;
public class DeploymentRecord
{
/// <summary>
/// The deployment record identifier.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The instance identifier being deployed.
/// </summary>
public int InstanceId { get; set; }
/// <summary>
/// The current deployment status.
/// </summary>
public DeploymentStatus Status { get; set; }
/// <summary>
/// The deployment identifier.
/// </summary>
public string DeploymentId { get; set; }
/// <summary>
/// The revision hash of the deployed configuration, or null.
/// </summary>
public string? RevisionHash { get; set; }
/// <summary>
/// The user who initiated the deployment.
/// </summary>
public string DeployedBy { get; set; }
/// <summary>
/// The time when the deployment was initiated.
/// </summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>
/// The time when the deployment completed, or null if still in progress.
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Error message if the deployment failed, or null.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
@@ -19,6 +54,11 @@ public class DeploymentRecord
/// </summary>
public byte[] RowVersion { get; set; } = [];
/// <summary>
/// Initializes a new instance of the DeploymentRecord.
/// </summary>
/// <param name="deploymentId">The deployment identifier.</param>
/// <param name="deployedBy">The user initiating the deployment.</param>
public DeploymentRecord(string deploymentId, string deployedBy)
{
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
@@ -1,13 +1,24 @@
namespace ScadaLink.Commons.Entities.Deployment;
/// <summary>
/// Records a system-wide artifact deployment operation, tracking status per site.
/// </summary>
public class SystemArtifactDeploymentRecord
{
/// <summary>Primary key.</summary>
public int Id { get; set; }
/// <summary>Type identifier for the deployed artifact (e.g. the artifact category name).</summary>
public string ArtifactType { get; set; }
/// <summary>Username of the operator who initiated the deployment.</summary>
public string DeployedBy { get; set; }
/// <summary>UTC timestamp when the deployment was initiated.</summary>
public DateTimeOffset DeployedAt { get; set; }
/// <summary>JSON-serialized per-site deployment status map, or null if not yet computed.</summary>
public string? PerSiteStatus { get; set; }
/// <summary>Initializes a new <see cref="SystemArtifactDeploymentRecord"/> with required fields.</summary>
/// <param name="artifactType">The artifact type being deployed.</param>
/// <param name="deployedBy">The username of the initiating operator.</param>
public SystemArtifactDeploymentRecord(string artifactType, string deployedBy)
{
ArtifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
@@ -2,12 +2,20 @@ namespace ScadaLink.Commons.Entities.ExternalSystems;
public class DatabaseConnectionDefinition
{
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; }
/// <summary>Gets or sets the human-readable connection name.</summary>
public string Name { get; set; }
/// <summary>Gets or sets the ADO.NET connection string for this database.</summary>
public string ConnectionString { get; set; }
/// <summary>Gets or sets the maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; }
/// <summary>Gets or sets the delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>Initializes a new <see cref="DatabaseConnectionDefinition"/> with the required name and connection string.</summary>
/// <param name="name">The human-readable connection name.</param>
/// <param name="connectionString">The ADO.NET connection string.</param>
public DatabaseConnectionDefinition(string name, string connectionString)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
@@ -2,14 +2,27 @@ namespace ScadaLink.Commons.Entities.ExternalSystems;
public class ExternalSystemDefinition
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the external system.</summary>
public string Name { get; set; }
/// <summary>Base URL of the external system's HTTP endpoint.</summary>
public string EndpointUrl { get; set; }
/// <summary>Authentication type identifier (e.g., "ApiKey", "Basic").</summary>
public string AuthType { get; set; }
/// <summary>JSON-serialized authentication configuration for the selected <see cref="AuthType"/>.</summary>
public string? AuthConfiguration { get; set; }
/// <summary>Maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; }
/// <summary>Fixed delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Initializes a new <see cref="ExternalSystemDefinition"/>.
/// </summary>
/// <param name="name">Display name for the external system.</param>
/// <param name="endpointUrl">Base URL of the external system's HTTP endpoint.</param>
/// <param name="authType">Authentication type identifier.</param>
public ExternalSystemDefinition(string name, string endpointUrl, string authType)
{
Name = name ?? throw new ArgumentNullException(nameof(name));

Some files were not shown because too many files have changed in this diff Show More