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 runtime data
docker-env2/*/logs/ docker-env2/*/logs/
docker-env2/*/data/ 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. - `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes. - `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 ## Document Conventions
- Requirements documents (high-level and component-level) live in `docs/requirements/`. - 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; /// later from the Akka host) can push without a friend reference;
/// readers should call <see cref="SiteAuditTelemetryStalled"/>. /// readers should call <see cref="SiteAuditTelemetryStalled"/>.
/// </summary> /// </summary>
/// <param name="evt">The event carrying the site ID and new stalled state.</param>
public void ApplyStalled(SiteAuditTelemetryStalledChanged evt) public void ApplyStalled(SiteAuditTelemetryStalledChanged evt)
{ {
if (evt is null) return; 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 /// lifetime exceeds the test, so the actor reuses the same instance across
/// every message. Used by Bundle D's MSSQL-backed TestKit fixture. /// every message. Used by Bundle D's MSSQL-backed TestKit fixture.
/// </summary> /// </summary>
/// <param name="repository">Audit log repository instance shared across all messages.</param>
/// <param name="logger">Logger for ingest diagnostics.</param>
public AuditLogIngestActor( public AuditLogIngestActor(
IAuditLogRepository repository, IAuditLogRepository repository,
ILogger<AuditLogIngestActor> logger) ILogger<AuditLogIngestActor> logger)
@@ -77,6 +79,8 @@ public class AuditLogIngestActor : ReceiveActor
/// is a long-lived cluster singleton, so it cannot hold a scope across /// is a long-lived cluster singleton, so it cannot hold a scope across
/// messages. /// messages.
/// </summary> /// </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( public AuditLogIngestActor(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<AuditLogIngestActor> logger) ILogger<AuditLogIngestActor> logger)
@@ -91,12 +95,7 @@ public class AuditLogIngestActor : ReceiveActor
ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryAsync); ReceiveAsync<IngestCachedTelemetryCommand>(OnCachedTelemetryAsync);
} }
/// <summary> /// <inheritdoc />
/// 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>
protected override SupervisorStrategy SupervisorStrategy() protected override SupervisorStrategy SupervisorStrategy()
{ {
return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider: return new OneForOneStrategy(maxNrOfRetries: 0, withinTimeRange: TimeSpan.Zero, decider:
@@ -60,6 +60,12 @@ public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDispo
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private Task? _loop; 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( public AuditLogPartitionMaintenanceService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
IOptions<AuditLogPartitionMaintenanceOptions> options, IOptions<AuditLogPartitionMaintenanceOptions> options,
@@ -61,6 +61,11 @@ public class AuditLogPurgeActor : ReceiveActor
private readonly ILogger<AuditLogPurgeActor> _logger; private readonly ILogger<AuditLogPurgeActor> _logger;
private ICancelable? _timer; 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( public AuditLogPurgeActor(
IServiceProvider services, IServiceProvider services,
IOptions<AuditLogPurgeOptions> purgeOptions, IOptions<AuditLogPurgeOptions> purgeOptions,
@@ -80,6 +85,7 @@ public class AuditLogPurgeActor : ReceiveActor
ReceiveAsync<PurgeTick>(_ => OnTickAsync()); ReceiveAsync<PurgeTick>(_ => OnTickAsync());
} }
/// <inheritdoc />
protected override void PreStart() protected override void PreStart()
{ {
base.PreStart(); base.PreStart();
@@ -92,17 +98,14 @@ public class AuditLogPurgeActor : ReceiveActor
sender: Self); sender: Self);
} }
/// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
_timer?.Cancel(); _timer?.Cancel();
base.PostStop(); base.PostStop();
} }
/// <summary> /// <inheritdoc />
/// 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>
protected override SupervisorStrategy SupervisorStrategy() protected override SupervisorStrategy SupervisorStrategy()
{ {
return new OneForOneStrategy( return new OneForOneStrategy(
@@ -47,6 +47,10 @@ public sealed class CentralAuditRedactionFailureCounter : IAuditRedactionFailure
{ {
private readonly AuditCentralHealthSnapshot _snapshot; 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) public CentralAuditRedactionFailureCounter(AuditCentralHealthSnapshot snapshot)
{ {
_snapshot = snapshot ?? throw new ArgumentNullException(nameof(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 /// provider simply leaves SourceNode at whatever the caller set (often
/// null, which is the legacy behaviour). /// null, which is the legacy behaviour).
/// </summary> /// </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( public CentralAuditWriter(
IServiceProvider services, IServiceProvider services,
ILogger<CentralAuditWriter> logger, ILogger<CentralAuditWriter> logger,
@@ -80,12 +85,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
_nodeIdentity = nodeIdentity; _nodeIdentity = nodeIdentity;
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
if (evt is null) if (evt is null)
@@ -37,6 +37,10 @@ public interface IPullAuditEventsClient
/// rows ordered oldest-first AND a <c>MoreAvailable</c> flag the actor /// rows ordered oldest-first AND a <c>MoreAvailable</c> flag the actor
/// uses to decide whether to fire another pull immediately. /// uses to decide whether to fire another pull immediately.
/// </summary> /// </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( Task<PullAuditEventsResponse> PullAsync(
string siteId, string siteId,
DateTime sinceUtc, DateTime sinceUtc,
@@ -22,6 +22,7 @@ public interface ISiteEnumerator
/// on the next tick. Implementations should reflect adds/removes promptly /// on the next tick. Implementations should reflect adds/removes promptly
/// — the actor calls this once per tick. /// — the actor calls this once per tick.
/// </summary> /// </summary>
/// <param name="ct">Cancellation token for the async enumeration.</param>
Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default); Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default);
} }
@@ -95,6 +95,14 @@ public class SiteAuditReconciliationActor : ReceiveActor
private ICancelable? _timer; 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( public SiteAuditReconciliationActor(
ISiteEnumerator sites, ISiteEnumerator sites,
IPullAuditEventsClient client, IPullAuditEventsClient client,
@@ -117,6 +125,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
ReceiveAsync<ReconciliationTick>(_ => OnTickAsync()); ReceiveAsync<ReconciliationTick>(_ => OnTickAsync());
} }
/// <inheritdoc />
protected override void PreStart() protected override void PreStart()
{ {
base.PreStart(); base.PreStart();
@@ -129,6 +138,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
sender: Self); sender: Self);
} }
/// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
_timer?.Cancel(); _timer?.Cancel();
@@ -301,11 +311,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
protected override SupervisorStrategy SupervisorStrategy() protected override SupervisorStrategy SupervisorStrategy()
{ {
return new OneForOneStrategy( return new OneForOneStrategy(
@@ -67,6 +67,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// <c>SiteAuditTelemetryStalledTrackerTests</c> use the ActorSystem ctor /// <c>SiteAuditTelemetryStalledTrackerTests</c> use the ActorSystem ctor
/// via Akka.TestKit so they exercise the production subscribe path. /// via Akka.TestKit so they exercise the production subscribe path.
/// </remarks> /// </remarks>
/// <param name="eventStream">The actor system event stream to observe.</param>
public SiteAuditTelemetryStalledTracker(EventStream eventStream) public SiteAuditTelemetryStalledTracker(EventStream eventStream)
: this(eventStream, snapshot: null) : this(eventStream, snapshot: null)
{ {
@@ -80,6 +81,8 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// subscribe (no actor system), but tests that drive the tracker via /// subscribe (no actor system), but tests that drive the tracker via
/// <see cref="Apply"/> get the snapshot push for free. /// <see cref="Apply"/> get the snapshot push for free.
/// </summary> /// </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) public SiteAuditTelemetryStalledTracker(EventStream eventStream, AuditCentralHealthSnapshot? snapshot)
{ {
_eventStream = eventStream ?? throw new ArgumentNullException(nameof(eventStream)); _eventStream = eventStream ?? throw new ArgumentNullException(nameof(eventStream));
@@ -94,6 +97,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// <see cref="SiteAuditTelemetryStalledChanged"/> updates the latched /// <see cref="SiteAuditTelemetryStalledChanged"/> updates the latched
/// per-site map. <see cref="Dispose"/> tears the subscriber down. /// per-site map. <see cref="Dispose"/> tears the subscriber down.
/// </summary> /// </summary>
/// <param name="actorSystem">The actor system whose EventStream will be subscribed.</param>
public SiteAuditTelemetryStalledTracker(ActorSystem actorSystem) public SiteAuditTelemetryStalledTracker(ActorSystem actorSystem)
: this(actorSystem, snapshot: null) : this(actorSystem, snapshot: null)
{ {
@@ -105,6 +109,8 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// shared <see cref="AuditCentralHealthSnapshot"/> so the central health /// shared <see cref="AuditCentralHealthSnapshot"/> so the central health
/// surface sees per-site stalled state without re-reading the tracker. /// surface sees per-site stalled state without re-reading the tracker.
/// </summary> /// </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) public SiteAuditTelemetryStalledTracker(ActorSystem actorSystem, AuditCentralHealthSnapshot? snapshot)
{ {
ArgumentNullException.ThrowIfNull(actorSystem); ArgumentNullException.ThrowIfNull(actorSystem);
@@ -136,6 +142,7 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
/// internally so tests against the bare-stream ctor can still drive the /// internally so tests against the bare-stream ctor can still drive the
/// tracker, but the production path always goes through the actor. /// tracker, but the production path always goes through the actor.
/// </summary> /// </summary>
/// <param name="evt">The stalled-state change event to apply.</param>
internal void Apply(SiteAuditTelemetryStalledChanged evt) internal void Apply(SiteAuditTelemetryStalledChanged evt)
{ {
if (evt is null) return; if (evt is null) return;
@@ -147,6 +154,9 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
_snapshot?.ApplyStalled(evt); _snapshot?.ApplyStalled(evt);
} }
/// <summary>
/// Disposes the tracker and tears down the internal subscriber actor.
/// </summary>
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;
@@ -173,12 +183,17 @@ public sealed class SiteAuditTelemetryStalledTracker : IDisposable
{ {
private readonly SiteAuditTelemetryStalledTracker _parent; 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) public StalledChangedSubscriber(SiteAuditTelemetryStalledTracker parent)
{ {
_parent = parent; _parent = parent;
Receive<SiteAuditTelemetryStalledChanged>(evt => _parent.Apply(evt)); Receive<SiteAuditTelemetryStalledChanged>(evt => _parent.Apply(evt));
} }
/// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
Context.System.EventStream.Unsubscribe(Self, typeof(SiteAuditTelemetryStalledChanged)); 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 /// counter from the container; a NoOp default is registered in
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/>. /// <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// </summary> /// </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( public DefaultAuditPayloadFilter(
IOptionsMonitor<AuditLogOptions> options, IOptionsMonitor<AuditLogOptions> options,
ILogger<DefaultAuditPayloadFilter> logger, ILogger<DefaultAuditPayloadFilter> logger,
@@ -113,6 +116,7 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter(); _failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
} }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent) public AuditEvent Apply(AuditEvent rawEvent)
{ {
try try
@@ -573,8 +577,11 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
{ {
public static readonly CompiledRegex Invalid = new(null); 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; } 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; 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 return a filtered copy. MUST NOT throw — on internal failure, over-redact
/// and surface the failure via the audit-redaction-failure health metric. /// and surface the failure via the audit-redaction-failure health metric.
/// </summary> /// </summary>
/// <param name="rawEvent">The unfiltered audit event to process.</param>
AuditEvent Apply(AuditEvent rawEvent); AuditEvent Apply(AuditEvent rawEvent);
} }
@@ -53,6 +53,9 @@ public static class ServiceCollectionExtensions
/// and the site-→central telemetry collaborators. Idempotent re-registration /// and the site-→central telemetry collaborators. Idempotent re-registration
/// is not supported; call this exactly once per <see cref="IServiceCollection"/>. /// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
/// </summary> /// </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) public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
@@ -252,6 +255,8 @@ public static class ServiceCollectionExtensions
/// ships in M6. /// ships in M6.
/// </para> /// </para>
/// </remarks> /// </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) public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
@@ -290,6 +295,9 @@ public static class ServiceCollectionExtensions
/// from any composition root" invariant. /// from any composition root" invariant.
/// </para> /// </para>
/// </remarks> /// </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( public static IServiceCollection AddAuditLogCentralMaintenance(
this IServiceCollection services, this IServiceCollection services,
IConfiguration config) IConfiguration config)
@@ -44,6 +44,11 @@ public sealed class FallbackAuditWriter : IAuditWriter
/// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration /// <see cref="ServiceCollectionExtensions.AddAuditLog"/> registration
/// always passes the real filter through. /// always passes the real filter through.
/// </summary> /// </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( public FallbackAuditWriter(
IAuditWriter primary, IAuditWriter primary,
RingBufferFallback ring, RingBufferFallback ring,
@@ -58,6 +63,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
_filter = filter; // null = no-op pass-through; see WriteAsync. _filter = filter; // null = no-op pass-through; see WriteAsync.
} }
/// <inheritdoc />
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
@@ -38,6 +38,8 @@ public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionF
{ {
private readonly ISiteHealthCollector _collector; 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) public HealthMetricsAuditRedactionFailureCounter(ISiteHealthCollector collector)
{ {
_collector = collector ?? throw new ArgumentNullException(nameof(collector)); _collector = collector ?? throw new ArgumentNullException(nameof(collector));
@@ -23,6 +23,10 @@ public sealed class HealthMetricsAuditWriteFailureCounter : IAuditWriteFailureCo
{ {
private readonly ISiteHealthCollector _collector; 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) public HealthMetricsAuditWriteFailureCounter(ISiteHealthCollector collector)
{ {
_collector = collector ?? throw new ArgumentNullException(nameof(collector)); _collector = collector ?? throw new ArgumentNullException(nameof(collector));
@@ -37,6 +37,8 @@ public sealed class RingBufferFallback
/// </summary> /// </summary>
public event Action? RingBufferOverflowed; 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) public RingBufferFallback(int capacity = 1024)
{ {
if (capacity <= 0) if (capacity <= 0)
@@ -62,6 +64,8 @@ public sealed class RingBufferFallback
/// <see langword="false"/> only when the ring has been /// <see langword="false"/> only when the ring has been
/// <see cref="Complete"/>-d. /// <see cref="Complete"/>-d.
/// </summary> /// </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) public bool TryEnqueue(AuditEvent evt)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
@@ -91,6 +95,7 @@ public sealed class RingBufferFallback
/// been called. Callers that only want to drain what's currently buffered /// been called. Callers that only want to drain what's currently buffered
/// must call <see cref="Complete"/> first. /// must call <see cref="Complete"/> first.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token to abort the async enumeration.</param>
public async IAsyncEnumerable<AuditEvent> DrainAsync( public async IAsyncEnumerable<AuditEvent> DrainAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -105,6 +110,8 @@ public sealed class RingBufferFallback
/// <see cref="FallbackAuditWriter"/> recovery path. Returns /// <see cref="FallbackAuditWriter"/> recovery path. Returns
/// <see langword="false"/> when the ring is empty. /// <see langword="false"/> when the ring is empty.
/// </summary> /// </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!); public bool TryDequeue(out AuditEvent evt) => _channel.Reader.TryRead(out evt!);
/// <summary> /// <summary>
@@ -52,6 +52,11 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private Task? _loop; 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( public SiteAuditBacklogReporter(
ISiteAuditQueue queue, ISiteAuditQueue queue,
ISiteHealthCollector collector, ISiteHealthCollector collector,
@@ -48,6 +48,11 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private readonly Task _writerLoop; private readonly Task _writerLoop;
private bool _disposed; 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( public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options, IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> logger, ILogger<SqliteAuditWriter> logger,
@@ -186,14 +191,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
alter.ExecuteNonQuery(); alter.ExecuteNonQuery();
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(evt); ArgumentNullException.ThrowIfNull(evt);
@@ -386,12 +384,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default) public Task<IReadOnlyList<AuditEvent>> ReadPendingAsync(int limit, CancellationToken ct = default)
{ {
if (limit <= 0) if (limit <= 0)
@@ -443,6 +436,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <see cref="ReadPendingSinceAsync"/>, which also returns /// <see cref="ReadPendingSinceAsync"/>, which also returns
/// <see cref="AuditForwardState.Pending"/> rows). /// <see cref="AuditForwardState.Pending"/> rows).
/// </summary> /// </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) public Task<IReadOnlyList<AuditEvent>> ReadForwardedAsync(int limit, CancellationToken ct = default)
{ {
if (limit <= 0) if (limit <= 0)
@@ -481,11 +476,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default) public Task MarkForwardedAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(eventIds); ArgumentNullException.ThrowIfNull(eventIds);
@@ -520,15 +511,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync( public Task<IReadOnlyList<AuditEvent>> ReadPendingSinceAsync(
DateTime sinceUtc, int batchSize, CancellationToken ct = default) DateTime sinceUtc, int batchSize, CancellationToken ct = default)
{ {
@@ -575,13 +558,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default) public Task MarkReconciledAsync(IReadOnlyList<Guid> eventIds, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(eventIds); ArgumentNullException.ThrowIfNull(eventIds);
@@ -616,22 +593,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
} }
} }
/// <summary> /// <inheritdoc />
/// 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>
public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default) public Task<SiteAuditBacklogSnapshot> GetBacklogStatsAsync(CancellationToken ct = default)
{ {
int pendingCount; int pendingCount;
@@ -731,11 +693,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}; };
} }
/// <summary>Disposes the audit writer and releases resources.</summary>
public void Dispose() public void Dispose()
{ {
DisposeAsync().AsTask().GetAwaiter().GetResult(); DisposeAsync().AsTask().GetAwaiter().GetResult();
} }
/// <summary>Asynchronously disposes the audit writer and releases resources.</summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
Task? writerLoop; Task? writerLoop;
@@ -779,13 +743,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>An audit event awaiting persistence by the background writer.</summary> /// <summary>An audit event awaiting persistence by the background writer.</summary>
private sealed class PendingAuditEvent 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) public PendingAuditEvent(AuditEvent evt)
{ {
Event = evt; Event = evt;
Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
} }
/// <summary>The audit event to persist.</summary>
public AuditEvent Event { get; } public AuditEvent Event { get; }
/// <summary>Task completion source for write completion signaling.</summary>
public TaskCompletionSource Completion { get; } public TaskCompletionSource Completion { get; }
} }
} }
@@ -48,6 +48,10 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
/// </summary> /// </summary>
private readonly INodeIdentityProvider? _nodeIdentity; 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( public CachedCallLifecycleBridge(
ICachedCallTelemetryForwarder forwarder, ICachedCallTelemetryForwarder forwarder,
ILogger<CachedCallLifecycleBridge> logger, ILogger<CachedCallLifecycleBridge> logger,
@@ -70,6 +70,10 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
/// registration. Production site nodes wire both — the central lazy /// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain. /// resolution is a no-op path kept symmetric with the M2 writer chain.
/// </summary> /// </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( public CachedCallTelemetryForwarder(
IAuditWriter auditWriter, IAuditWriter auditWriter,
IOperationTrackingStore? trackingStore, IOperationTrackingStore? trackingStore,
@@ -82,13 +86,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
_nodeIdentity = nodeIdentity; _nodeIdentity = nodeIdentity;
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(telemetry); ArgumentNullException.ThrowIfNull(telemetry);
@@ -20,6 +20,8 @@ public interface ISiteStreamAuditClient
/// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/> /// <see cref="ScadaLink.Commons.Types.Enums.AuditForwardState.Forwarded"/>
/// in the site SQLite queue. /// in the site SQLite queue.
/// </summary> /// </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); Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary> /// <summary>
@@ -38,5 +40,7 @@ public interface ISiteStreamAuditClient
/// DI default (used by central and test composition roots) returns an empty /// DI default (used by central and test composition roots) returns an empty
/// ack so no rows are flipped. /// ack so no rows are flipped.
/// </remarks> /// </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); Task<IngestAck> IngestCachedTelemetryAsync(CachedTelemetryBatch batch, CancellationToken ct);
} }
@@ -43,6 +43,11 @@ public class SiteAuditTelemetryActor : ReceiveActor
private readonly ILogger<SiteAuditTelemetryActor> _logger; private readonly ILogger<SiteAuditTelemetryActor> _logger;
private ICancelable? _pendingTick; 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( public SiteAuditTelemetryActor(
ISiteAuditQueue queue, ISiteAuditQueue queue,
ISiteStreamAuditClient client, ISiteStreamAuditClient client,
@@ -62,6 +67,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
ReceiveAsync<Drain>(_ => OnDrainAsync()); ReceiveAsync<Drain>(_ => OnDrainAsync());
} }
/// <inheritdoc />
protected override void PreStart() protected override void PreStart()
{ {
base.PreStart(); base.PreStart();
@@ -71,6 +77,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
ScheduleNext(TimeSpan.FromSeconds(_options.BusyIntervalSeconds)); ScheduleNext(TimeSpan.FromSeconds(_options.BusyIntervalSeconds));
} }
/// <inheritdoc />
protected override void PostStop() protected override void PostStop()
{ {
_pendingTick?.Cancel(); _pendingTick?.Cancel();
+11
View File
@@ -2,9 +2,14 @@ using System.Text.Json;
namespace ScadaLink.CLI; namespace ScadaLink.CLI;
/// <summary>
/// Resolved CLI configuration combining config file values, environment variable overrides, and per-invocation credentials.
/// </summary>
public class CliConfig public class CliConfig
{ {
/// <summary>Base URL of the ScadaLink Management API (e.g. http://localhost:9000).</summary>
public string? ManagementUrl { get; set; } public string? ManagementUrl { get; set; }
/// <summary>Default output format for CLI commands; defaults to "json".</summary>
public string DefaultFormat { get; set; } = "json"; public string DefaultFormat { get; set; } = "json";
/// <summary> /// <summary>
@@ -21,6 +26,10 @@ public class CliConfig
/// </summary> /// </summary>
public string? Password { get; set; } 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() public static CliConfig Load()
{ {
var config = new CliConfig(); var config = new CliConfig();
@@ -66,7 +75,9 @@ public class CliConfig
private class CliConfigFile private class CliConfigFile
{ {
/// <summary>Management API URL from the config file.</summary>
public string? ManagementUrl { get; set; } public string? ManagementUrl { get; set; }
/// <summary>Default output format from the config file.</summary>
public string? DefaultFormat { get; set; } public string? DefaultFormat { get; set; }
} }
} }
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class ApiMethodCommands 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) 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" }; var command = new Command("api-method") { Description = "Manage inbound API methods" };
@@ -9,12 +9,37 @@ namespace ScadaLink.CLI.Commands;
/// </summary> /// </summary>
public sealed class AuditConnection public sealed class AuditConnection
{ {
/// <summary>
/// The management URL, or null if resolution failed.
/// </summary>
public string? Url { get; init; } public string? Url { get; init; }
/// <summary>
/// The username for authentication, or null if resolution failed.
/// </summary>
public string? Username { get; init; } public string? Username { get; init; }
/// <summary>
/// The password for authentication, or null if resolution failed.
/// </summary>
public string? Password { get; init; } public string? Password { get; init; }
/// <summary>
/// Error message if resolution failed, or null.
/// </summary>
public string? Error { get; init; } public string? Error { get; init; }
/// <summary>
/// Error code if resolution failed, or null.
/// </summary>
public string? ErrorCode { get; init; } 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) public static AuditConnection Fail(string error, string code)
=> new() { Error = error, ErrorCode = code }; => new() { Error = error, ErrorCode = code };
} }
@@ -28,6 +53,14 @@ public sealed class AuditConnection
/// </summary> /// </summary>
public static class AuditCommandHelpers 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( public static AuditConnection ResolveConnection(
ParseResult result, ParseResult result,
Option<string> urlOption, Option<string> urlOption,
@@ -67,6 +100,12 @@ public static class AuditCommandHelpers
return new AuditConnection { Url = url, Username = username, Password = password }; 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) public static string ResolveFormat(ParseResult result, Option<string> formatOption)
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()); => CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
} }
@@ -11,6 +11,13 @@ namespace ScadaLink.CLI.Commands;
/// </summary> /// </summary>
public static class AuditCommands 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) 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" }; var command = new Command("audit") { Description = "Query and export the centralized audit log" };
@@ -13,15 +13,45 @@ namespace ScadaLink.CLI.Commands;
/// </summary> /// </summary>
public sealed class AuditExportArgs public sealed class AuditExportArgs
{ {
/// <summary>
/// Start timestamp for the export time window.
/// </summary>
public string Since { get; set; } = string.Empty; public string Since { get; set; } = string.Empty;
/// <summary>
/// End timestamp for the export time window.
/// </summary>
public string Until { get; set; } = string.Empty; public string Until { get; set; } = string.Empty;
/// <summary>
/// Export format (e.g., 'json', 'csv', 'parquet').
/// </summary>
public string Format { get; set; } = string.Empty; public string Format { get; set; } = string.Empty;
/// <summary>
/// Output file path for the exported audit log.
/// </summary>
public string Output { get; set; } = string.Empty; public string Output { get; set; } = string.Empty;
/// <summary>
/// Channel filter values (repeated query parameter).
/// </summary>
public string[] Channel { get; set; } = Array.Empty<string>(); public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>
/// Kind filter values (repeated query parameter).
/// </summary>
public string[] Kind { get; set; } = Array.Empty<string>(); public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>
/// Status filter values (repeated query parameter).
/// </summary>
public string[] Status { get; set; } = Array.Empty<string>(); 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>(); public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>
/// Optional target system filter.
/// </summary>
public string? Target { get; set; } public string? Target { get; set; }
/// <summary>
/// Optional actor/user filter.
/// </summary>
public string? Actor { get; set; } 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 /// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
/// <see cref="AuditQueryHelpers.BuildQueryString"/>. /// <see cref="AuditQueryHelpers.BuildQueryString"/>.
/// </summary> /// </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) public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
{ {
var parts = new List<string>(); 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 /// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
/// server message and returns a non-zero exit code. /// server message and returns a non-zero exit code.
/// </summary> /// </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( public static async Task<int> RunExportAsync(
ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now)
{ {
@@ -10,6 +10,8 @@ namespace ScadaLink.CLI.Commands;
public interface IAuditFormatter public interface IAuditFormatter
{ {
/// <summary>Renders one page of events. Called once per fetched page.</summary> /// <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); void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
} }
@@ -21,6 +23,7 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
{ {
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output) public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{ {
foreach (var evt in events) foreach (var evt in events)
@@ -35,6 +38,11 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
/// </summary> /// </summary>
public static class AuditFormatterFactory 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) public static IAuditFormatter Create(string format, TextWriter notices)
{ {
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) 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. /// 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. /// Factored out of <c>Program.cs</c> so it is unit-testable without spawning a process.
/// </summary> /// </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) public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr)
{ {
if (args.Length > 0 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) 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" }; var command = new Command("audit-config") { Description = "Query the configuration-change audit log" };
@@ -15,18 +15,31 @@ namespace ScadaLink.CLI.Commands;
/// </summary> /// </summary>
public sealed class AuditQueryArgs public sealed class AuditQueryArgs
{ {
/// <summary>Start time spec (relative like 1h, or absolute ISO-8601).</summary>
public string? Since { get; set; } public string? Since { get; set; }
/// <summary>End time spec (relative like 7d, or absolute ISO-8601).</summary>
public string? Until { get; set; } public string? Until { get; set; }
/// <summary>Multi-valued channel filter.</summary>
public string[] Channel { get; set; } = Array.Empty<string>(); public string[] Channel { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued audit event kind filter.</summary>
public string[] Kind { get; set; } = Array.Empty<string>(); public string[] Kind { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued status filter.</summary>
public string[] Status { get; set; } = Array.Empty<string>(); public string[] Status { get; set; } = Array.Empty<string>();
/// <summary>Multi-valued site ID filter.</summary>
public string[] Site { get; set; } = Array.Empty<string>(); public string[] Site { get; set; } = Array.Empty<string>();
/// <summary>Target system or service filter.</summary>
public string? Target { get; set; } public string? Target { get; set; }
/// <summary>Actor (user or system) filter.</summary>
public string? Actor { get; set; } public string? Actor { get; set; }
/// <summary>Operation correlation ID filter.</summary>
public string? CorrelationId { get; set; } public string? CorrelationId { get; set; }
/// <summary>Script execution ID filter.</summary>
public string? ExecutionId { get; set; } public string? ExecutionId { get; set; }
/// <summary>Parent execution ID filter.</summary>
public string? ParentExecutionId { get; set; } public string? ParentExecutionId { get; set; }
/// <summary>Filter for errors only (status=Failed).</summary>
public bool ErrorsOnly { get; set; } public bool ErrorsOnly { get; set; }
/// <summary>Page size for pagination.</summary>
public int PageSize { get; set; } = 100; 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 /// 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. /// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
/// </summary> /// </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> /// <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) 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> /// 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>. /// maps to a single <c>status=Failed</c> and overrides any explicit <c>--status</c>.
/// </summary> /// </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( public static string BuildQueryString(
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) 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 /// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
/// process exit code (0 success, non-zero on HTTP/transport error). /// process exit code (0 success, non-zero on HTTP/transport error).
/// </summary> /// </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( public static async Task<int> RunQueryAsync(
ManagementHttpClient client, ManagementHttpClient client,
AuditQueryArgs args, 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 /// 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. /// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
/// </summary> /// </summary>
/// <param name="month">The month string to validate in YYYY-MM format.</param>
public static bool IsValidMonth(string? month) public static bool IsValidMonth(string? month)
=> !string.IsNullOrWhiteSpace(month) => !string.IsNullOrWhiteSpace(month)
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
@@ -15,6 +15,12 @@ public static class BundleCommands
{ {
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5); 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( public static Command Build(
Option<string> urlOption, Option<string> formatOption, Option<string> urlOption, Option<string> formatOption,
Option<string> usernameOption, Option<string> passwordOption) Option<string> usernameOption, Option<string> passwordOption)
@@ -7,6 +7,16 @@ namespace ScadaLink.CLI.Commands;
internal static class CommandHelpers 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( internal static async Task<int> ExecuteCommandAsync(
ParseResult result, ParseResult result,
Option<string> urlOption, 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 /// 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. /// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
/// </summary> /// </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) internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
{ {
// GetResult returns non-null only when the option was actually present on the // 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 /// Resolves a single credential: an explicit command-line value wins, otherwise the
/// environment-variable fallback (from <see cref="CliConfig"/>) is used. /// environment-variable fallback (from <see cref="CliConfig"/>) is used.
/// </summary> /// </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) internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue; => 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 /// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
/// an unhandled <see cref="UriFormatException"/>. /// an unhandled <see cref="UriFormatException"/>.
/// </summary> /// </summary>
/// <param name="url">URL string to validate.</param>
internal static bool IsValidManagementUrl(string? url) internal static bool IsValidManagementUrl(string? url)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
@@ -105,6 +121,11 @@ internal static class CommandHelpers
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); && (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) internal static int HandleResponse(ManagementResponse response, string format)
{ {
if (response.JsonData != null) if (response.JsonData != null)
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class DataConnectionCommands 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) 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" }; var command = new Command("data-connection") { Description = "Manage data connections" };
@@ -4,8 +4,17 @@ using ScadaLink.Commons.Messages.Management;
namespace ScadaLink.CLI.Commands; namespace ScadaLink.CLI.Commands;
/// <summary>
/// CLI commands for managing database connection definitions.
/// </summary>
public static class DbConnectionCommands 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) 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" }; var command = new Command("db-connection") { Description = "Manage database connections" };
@@ -10,6 +10,11 @@ namespace ScadaLink.CLI.Commands;
public static class DebugCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("debug") { Description = "Runtime debugging" }; 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. /// (Ctrl+C during connect) is a graceful shutdown — exit 0, no error printed.
/// Anything else is a genuine connection failure — exit 1. /// Anything else is a genuine connection failure — exit 1.
/// </summary> /// </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) internal static ConnectFailure ClassifyConnectFailure(Exception ex, bool cancellationRequested)
{ {
if (cancellationRequested && ex is OperationCanceledException) 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 /// 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. /// result is ever produced (pure Ctrl+C), the stream ended gracefully — exit 0.
/// </summary> /// </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) internal static async Task<int> ResolveStreamExitCodeAsync(Task<int> exitTask)
{ {
if (exitTask.IsCompletedSuccessfully) if (exitTask.IsCompletedSuccessfully)
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class DeployCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("deploy") { Description = "Deployment operations" }; var command = new Command("deploy") { Description = "Deployment operations" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class ExternalSystemCommands 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) 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" }; var command = new Command("external-system") { Description = "Manage external systems" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class HealthCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("health") { Description = "Health monitoring" }; var command = new Command("health") { Description = "Health monitoring" };
@@ -6,6 +6,14 @@ namespace ScadaLink.CLI.Commands;
public static class InstanceCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("instance") { Description = "Manage instances" }; 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 /// throwing when the JSON is malformed, a pair has the wrong arity, or an element
/// has the wrong type. /// has the wrong type.
/// </summary> /// </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( internal static bool TryParseBindings(
string json, string json,
out List<ConnectionBinding>? bindings, out List<ConnectionBinding>? bindings,
@@ -126,6 +138,10 @@ public static class InstanceCommands
/// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing /// <c>false</c> with a descriptive <paramref name="error"/> instead of throwing
/// when the JSON is malformed or null. /// when the JSON is malformed or null.
/// </summary> /// </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( internal static bool TryParseOverrides(
string json, string json,
out Dictionary<string, string?>? overrides, out Dictionary<string, string?>? overrides,
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class NotificationCommands 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) 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" }; 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 /// 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. /// null when omitted so the server-side handler preserves the existing values.
/// </summary> /// </summary>
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result) internal static UpdateSmtpConfigCommand BuildUpdateSmtpConfigCommand(ParseResult result)
{ {
var id = result.GetValue(SmtpIdOption); var id = result.GetValue(SmtpIdOption);
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class SecurityCommands 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) 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" }; var command = new Command("security") { Description = "Manage security settings" };
@@ -6,6 +6,12 @@ namespace ScadaLink.CLI.Commands;
public static class SharedScriptCommands 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) 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" }; var command = new Command("shared-script") { Description = "Manage shared scripts" };
@@ -6,6 +6,13 @@ namespace ScadaLink.CLI.Commands;
public static class SiteCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("site") { Description = "Manage sites" }; var command = new Command("site") { Description = "Manage sites" };
@@ -28,6 +28,7 @@ public sealed class TableAuditFormatter : IAuditFormatter
("httpStatus", "HttpStatus", 10), ("httpStatus", "HttpStatus", 10),
}; };
/// <inheritdoc />
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output) public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
{ {
// Build every cell first so column widths account for the actual data. // Build every cell first so column widths account for the actual data.
@@ -6,6 +6,11 @@ namespace ScadaLink.CLI.Commands;
public static class TemplateCommands 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) public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var command = new Command("template") { Description = "Manage templates" }; var command = new Command("template") { Description = "Manage templates" };
+25
View File
@@ -8,6 +8,12 @@ public class ManagementHttpClient : IDisposable
{ {
private readonly HttpClient _httpClient; 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) public ManagementHttpClient(string baseUrl, string username, string password)
: this(new HttpClient(), baseUrl, username, 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 /// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
/// be exercised without a live server. /// be exercised without a live server.
/// </summary> /// </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) internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
{ {
_httpClient = httpClient; _httpClient = httpClient;
@@ -27,6 +37,13 @@ public class ManagementHttpClient : IDisposable
new AuthenticationHeaderValue("Basic", credentials); 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) public async Task<ManagementResponse> SendCommandAsync(string commandName, object payload, TimeSpan timeout)
{ {
using var cts = new CancellationTokenSource(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. /// REST resources. Authentication (HTTP Basic) and the base address are shared.
/// </summary> /// </summary>
/// <param name="relativePath">Path relative to the base URL, with query string.</param> /// <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) public async Task<ManagementResponse> SendGetAsync(string relativePath, TimeSpan timeout)
{ {
using var cts = new CancellationTokenSource(timeout); using var cts = new CancellationTokenSource(timeout);
@@ -130,9 +149,15 @@ public class ManagementHttpClient : IDisposable
/// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/> /// disposing the returned message. The <see cref="HttpCompletionOption.ResponseHeadersRead"/>
/// option ensures the body is not pre-buffered. /// option ensures the body is not pre-buffered.
/// </summary> /// </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) public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
/// <summary>
/// Disposes the underlying HTTP client.
/// </summary>
public void Dispose() => _httpClient.Dispose(); public void Dispose() => _httpClient.Dispose();
} }
+8
View File
@@ -12,16 +12,24 @@ public static class OutputFormatter
PropertyNamingPolicy = JsonNamingPolicy.CamelCase 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) public static void WriteJson(object? data)
{ {
Console.WriteLine(JsonSerializer.Serialize(data, JsonOptions)); 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) public static void WriteError(string message, string code)
{ {
Console.Error.WriteLine(JsonSerializer.Serialize(new { error = message, code }, JsonOptions)); 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) public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
{ {
var allRows = new List<string[]> { headers }; var allRows = new List<string[]> { headers };
@@ -43,6 +43,9 @@ public static class AuditExportEndpoints
/// </summary> /// </summary>
public const int DefaultMaxRows = 100_000; 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) public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{ {
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync) 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 /// tests can call it directly when desirable; the live wire-up goes
/// through the minimal-API map above. /// through the minimal-API map above.
/// </summary> /// </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) internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{ {
var filter = ParseFilter(context.Request.Query); var filter = ParseFilter(context.Request.Query);
@@ -88,6 +93,7 @@ public static class AuditExportEndpoints
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches /// <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. /// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks> /// </remarks>
/// <param name="query">The query string parameters from the HTTP request.</param>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{ {
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]); var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
@@ -15,6 +15,8 @@ namespace ScadaLink.CentralUI.Auth;
/// </summary> /// </summary>
public static class AuthEndpoints 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) public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
{ {
endpoints.MapPost("/auth/login", async (HttpContext context) => 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 /// cookie session is still valid and <c>401</c> once it has lapsed
/// server-side. See CentralUI-020. /// server-side. See CentralUI-020.
/// </summary> /// </summary>
/// <param name="context">The current HTTP context used to check authentication state and write the response.</param>
public static Task HandlePing(HttpContext context) public static Task HandlePing(HttpContext context)
{ {
context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true context.Response.StatusCode = context.User.Identity?.IsAuthenticated == true
@@ -19,6 +19,7 @@ public static class ClaimsPrincipalExtensions
/// The audit username for <paramref name="principal"/>, or /// The audit username for <paramref name="principal"/>, or
/// <see cref="UnknownUser"/> when the claim is absent. /// <see cref="UnknownUser"/> when the claim is absent.
/// </summary> /// </summary>
/// <param name="principal">The claims principal to read the username from.</param>
public static string GetUsername(this ClaimsPrincipal principal) public static string GetUsername(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? UnknownUser; => 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 display name for <paramref name="principal"/>, or <c>null</c> when
/// the claim is absent. /// the claim is absent.
/// </summary> /// </summary>
/// <param name="principal">The claims principal to read the display name from.</param>
public static string? GetDisplayName(this ClaimsPrincipal principal) public static string? GetDisplayName(this ClaimsPrincipal principal)
=> principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value; => principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
@@ -34,6 +36,7 @@ public static class ClaimsPrincipalExtensions
/// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into /// Replaces the <c>GetCurrentUserAsync</c> helper that was copy-pasted into
/// ten components (CentralUI-024). /// ten components (CentralUI-024).
/// </summary> /// </summary>
/// <param name="authStateProvider">The Blazor authentication state provider to read from.</param>
public static async Task<string> GetCurrentUsernameAsync( public static async Task<string> GetCurrentUsernameAsync(
this AuthenticationStateProvider authStateProvider) this AuthenticationStateProvider authStateProvider)
{ {
@@ -28,6 +28,10 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
{ {
private readonly Task<AuthenticationState> _circuitAuthState; 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) public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
{ {
// Snapshot the principal at circuit-construction time. HttpContext is // Snapshot the principal at circuit-construction time. HttpContext is
@@ -38,6 +42,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
_circuitAuthState = Task.FromResult(new AuthenticationState(user)); _circuitAuthState = Task.FromResult(new AuthenticationState(user));
} }
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _circuitAuthState; => _circuitAuthState;
} }
@@ -27,6 +27,8 @@ public sealed class SiteScopeService
private readonly AuthenticationStateProvider _authStateProvider; private readonly AuthenticationStateProvider _authStateProvider;
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached; 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) public SiteScopeService(AuthenticationStateProvider authStateProvider)
{ {
_authStateProvider = authStateProvider; _authStateProvider = authStateProvider;
@@ -51,6 +53,7 @@ public sealed class SiteScopeService
/// Returns the subset of <paramref name="sites"/> the user is permitted to /// Returns the subset of <paramref name="sites"/> the user is permitted to
/// see. A system-wide user gets the full list back unchanged. /// see. A system-wide user gets the full list back unchanged.
/// </summary> /// </summary>
/// <param name="sites">The full set of sites to filter.</param>
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites) public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
{ {
var (isSystemWide, allowed) = await ResolveAsync(); 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>. /// 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. /// Must be re-checked server-side before any mutating cross-site command.
/// </summary> /// </summary>
/// <param name="siteId">The <c>Site.Id</c> to check.</param>
public async Task<bool> IsSiteAllowedAsync(int siteId) public async Task<bool> IsSiteAllowedAsync(int siteId)
{ {
var (isSystemWide, allowed) = await ResolveAsync(); var (isSystemWide, allowed) = await ResolveAsync();
@@ -63,6 +63,7 @@ public partial class AuditFilterBar
/// </summary> /// </summary>
[Parameter] public string? InitialInstanceSearch { get; set; } [Parameter] public string? InitialInstanceSearch { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// One-shot prefill from a drill-in deep link. Subsequent parameter changes // One-shot prefill from a drill-in deep link. Subsequent parameter changes
@@ -33,9 +33,13 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// </summary> /// </summary>
public sealed class AuditQueryModel public sealed class AuditQueryModel
{ {
/// <summary>Selected channel filter chips; empty means all channels.</summary>
public HashSet<AuditChannel> Channels { get; } = new(); public HashSet<AuditChannel> Channels { get; } = new();
/// <summary>Selected kind filter chips; empty means all kinds.</summary>
public HashSet<AuditKind> Kinds { get; } = new(); public HashSet<AuditKind> Kinds { get; } = new();
/// <summary>Selected status filter chips; empty means all statuses.</summary>
public HashSet<AuditStatus> Statuses { get; } = new(); 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); public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary> /// <summary>
@@ -46,13 +50,20 @@ public sealed class AuditQueryModel
/// </summary> /// </summary>
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase); 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; 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; } 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; } 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; 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; 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; 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; public string ActorSearch { get; set; } = string.Empty;
/// <summary> /// <summary>
@@ -72,6 +83,7 @@ public sealed class AuditQueryModel
/// </summary> /// </summary>
public string ParentExecutionId { get; set; } = string.Empty; 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; } public bool ErrorsOnly { get; set; }
/// <summary> /// <summary>
@@ -133,6 +145,8 @@ public sealed class AuditQueryModel
/// multi-select maps straight through to its filter list (an empty set yields /// 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. /// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary> /// </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) public AuditLogQueryFilter ToFilter(DateTime utcNow)
{ {
var statuses = ResolveStatuses(); var statuses = ResolveStatuses();
@@ -183,6 +183,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
? $"--audit-col-width: {width}px;" ? $"--audit-col-width: {width}px;"
: string.Empty; : string.Empty;
/// <inheritdoc />
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter // 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) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
@@ -366,6 +368,8 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// JS callback: the user finished resizing a column. Persists the new /// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header. /// per-column width and re-renders so the body cells track the header.
/// </summary> /// </summary>
/// <param name="columnKey">The stable key of the resized column.</param>
/// <param name="widthPx">The new column width in pixels.</param>
[JSInvokable] [JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx) 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 /// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders. /// target's slot, persists the resulting order, and re-renders.
/// </summary> /// </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] [JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey) 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() public ValueTask DisposeAsync()
{ {
_selfRef?.Dispose(); _selfRef?.Dispose();
@@ -80,6 +80,7 @@ public partial class ExecutionDetailModal
/// </summary> /// </summary>
private const int RowPageSize = 100; private const int RowPageSize = 100;
/// <inheritdoc />
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// Load only on the closed → open transition. A re-render while already // 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. // the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new(); private readonly HashSet<Guid> _collapsed = new();
/// <inheritdoc />
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
// Nested instance: the parent already assembled our subtrees. // Nested instance: the parent already assembled our subtrees.
@@ -54,6 +54,7 @@ public partial class AuditLogPage : IDisposable
private bool _drawerOpen; private bool _drawerOpen;
private string? _initialInstanceSearch; private string? _initialInstanceSearch;
/// <inheritdoc />
protected override void OnInitialized() protected override void OnInitialized()
{ {
ApplyQueryStringFilters(); 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() public void Dispose()
{ {
Navigation.LocationChanged -= HandleLocationChanged; Navigation.LocationChanged -= HandleLocationChanged;
@@ -248,6 +250,10 @@ public partial class AuditLogPage : IDisposable
/// </remarks> /// </remarks>
internal string ExportUrl => BuildExportUrl(_currentFilter); 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) internal static string BuildExportUrl(AuditLogQueryFilter? filter)
{ {
const string basePath = "/api/centralui/audit/export"; const string basePath = "/api/centralui/audit/export";
@@ -49,6 +49,7 @@ public partial class ExecutionTreePage
private Guid? _modalExecutionId; private Guid? _modalExecutionId;
private bool _modalOpen; private bool _modalOpen;
/// <inheritdoc />
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_executionId = ParseExecutionId(); _executionId = ParseExecutionId();
@@ -105,6 +105,7 @@ public partial class TransportExport : ComponentBase
private bool _downloadInProgress; private bool _downloadInProgress;
private string? _downloadError; private string? _downloadError;
/// <inheritdoc />
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadAllAsync(); await LoadAllAsync();
@@ -181,6 +182,7 @@ public partial class TransportExport : ComponentBase
/// to colour an inline strength meter; never used to gate the export — the /// to colour an inline strength meter; never used to gate the export — the
/// importer enforces its own strength + lockout policies. /// importer enforces its own strength + lockout policies.
/// </summary> /// </summary>
/// <param name="s">The passphrase string to score.</param>
internal static int PassphraseStrength(string s) internal static int PassphraseStrength(string s)
{ {
if (string.IsNullOrEmpty(s)) return 0; 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 /// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
/// knows exactly what an unencrypted export would leak. /// knows exactly what an unencrypted export would leak.
/// </summary> /// </summary>
/// <param name="resolved">The resolved export closure whose secret fields are counted.</param>
internal static int CountSecrets(ResolvedExport resolved) internal static int CountSecrets(ResolvedExport resolved)
{ {
var count = 0; var count = 0;
@@ -363,6 +366,8 @@ public partial class TransportExport : ComponentBase
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce /// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
/// browser-rejected filenames. /// browser-rejected filenames.
/// </summary> /// </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) internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
{ {
var safe = SanitizeForFilename(sourceEnvironment); 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"/> — /// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
/// the auto-included dependencies the resolver pulled in for the user. /// the auto-included dependencies the resolver pulled in for the user.
/// </summary> /// </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) 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(); return all.Where(x => !seed.Contains(idOf(x))).ToList();
@@ -353,6 +353,8 @@ public partial class TransportImport : ComponentBase
/// </list> /// </list>
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned. /// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
/// </summary> /// </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( internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
ImportPreview preview) ImportPreview preview)
{ {
@@ -89,6 +89,7 @@ public partial class SiteCallsReport
private bool HasNextPage => _nextCursor is not null; private bool HasNextPage => _nextCursor is not null;
/// <inheritdoc />
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
@@ -31,6 +31,9 @@ internal static class AlarmTriggerConfigCodec
/// type. Returns a model with default values on null/empty/malformed input /// type. Returns a model with default values on null/empty/malformed input
/// or for missing keys — never throws. /// or for missing keys — never throws.
/// </summary> /// </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) internal static AlarmTriggerModel Parse(string? json, AlarmTriggerType type)
{ {
var model = new AlarmTriggerModel(); var model = new AlarmTriggerModel();
@@ -115,6 +118,9 @@ internal static class AlarmTriggerConfigCodec
/// current trigger type. <c>Expression</c> is not bound to a single /// current trigger type. <c>Expression</c> is not bound to a single
/// attribute, so <c>attributeName</c> is omitted for it. /// attribute, so <c>attributeName</c> is omitted for it.
/// </summary> /// </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) internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
{ {
using var stream = new MemoryStream(); using var stream = new MemoryStream();
@@ -174,6 +180,11 @@ internal static class AlarmTriggerConfigCodec
return Encoding.UTF8.GetString(stream.ToArray()); 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 internal static string NormalizeDirection(string? raw) => raw?.ToLowerInvariant() switch
{ {
"rising" or "up" or "positive" => "rising", "rising" or "up" or "positive" => "rising",
@@ -213,47 +224,122 @@ internal static class AlarmTriggerConfigCodec
internal sealed class AlarmTriggerModel internal sealed class AlarmTriggerModel
{ {
/// <summary>
/// The attribute name bound to this trigger.
/// </summary>
public string? AttributeName { get; set; } public string? AttributeName { get; set; }
// ValueMatch // ValueMatch
/// <summary>
/// The value to match against the attribute for ValueMatch triggers.
/// </summary>
public string? MatchValue { get; set; } public string? MatchValue { get; set; }
/// <summary>
/// Indicates whether the match should be inverted (not equal) for ValueMatch triggers.
/// </summary>
public bool NotEquals { get; set; } public bool NotEquals { get; set; }
// RangeViolation // RangeViolation
/// <summary>
/// The minimum threshold for RangeViolation triggers.
/// </summary>
public double? Min { get; set; } public double? Min { get; set; }
/// <summary>
/// The maximum threshold for RangeViolation triggers.
/// </summary>
public double? Max { get; set; } public double? Max { get; set; }
// RateOfChange // RateOfChange
/// <summary>
/// The threshold per second for RateOfChange triggers.
/// </summary>
public double? ThresholdPerSecond { get; set; } public double? ThresholdPerSecond { get; set; }
/// <summary>
/// The time window in seconds for RateOfChange rate calculation.
/// </summary>
public double? WindowSeconds { get; set; } public double? WindowSeconds { get; set; }
/// <summary>
/// The direction of change: "rising", "falling", or "either" for RateOfChange triggers.
/// </summary>
public string Direction { get; set; } = "either"; public string Direction { get; set; } = "either";
// HiLo — any subset of setpoints may be set; per-setpoint priorities // HiLo — any subset of setpoints may be set; per-setpoint priorities
// override the alarm-level priority for that band. // override the alarm-level priority for that band.
/// <summary>
/// The low-low setpoint for HiLo triggers.
/// </summary>
public double? LoLo { get; set; } public double? LoLo { get; set; }
/// <summary>
/// The low setpoint for HiLo triggers.
/// </summary>
public double? Lo { get; set; } public double? Lo { get; set; }
/// <summary>
/// The high setpoint for HiLo triggers.
/// </summary>
public double? Hi { get; set; } public double? Hi { get; set; }
/// <summary>
/// The high-high setpoint for HiLo triggers.
/// </summary>
public double? HiHi { get; set; } public double? HiHi { get; set; }
/// <summary>
/// The priority for low-low alarm state.
/// </summary>
public int? LoLoPriority { get; set; } public int? LoLoPriority { get; set; }
/// <summary>
/// The priority for low alarm state.
/// </summary>
public int? LoPriority { get; set; } public int? LoPriority { get; set; }
/// <summary>
/// The priority for high alarm state.
/// </summary>
public int? HiPriority { get; set; } public int? HiPriority { get; set; }
/// <summary>
/// The priority for high-high alarm state.
/// </summary>
public int? HiHiPriority { get; set; } public int? HiHiPriority { get; set; }
// Hysteresis: optional deactivation deadband per setpoint. Once at the // Hysteresis: optional deactivation deadband per setpoint. Once at the
// band, the setpoint threshold is relaxed by this amount before the alarm // band, the setpoint threshold is relaxed by this amount before the alarm
// de-escalates. Prevents flapping when the value hovers at the boundary. // 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; } public double? LoLoDeadband { get; set; }
/// <summary>
/// The deadband for low alarm de-escalation.
/// </summary>
public double? LoDeadband { get; set; } public double? LoDeadband { get; set; }
/// <summary>
/// The deadband for high alarm de-escalation.
/// </summary>
public double? HiDeadband { get; set; } public double? HiDeadband { get; set; }
/// <summary>
/// The deadband for high-high alarm de-escalation.
/// </summary>
public double? HiHiDeadband { get; set; } public double? HiHiDeadband { get; set; }
// Per-band operator message. Optional; surfaces on AlarmStateChanged.Message // Per-band operator message. Optional; surfaces on AlarmStateChanged.Message
// and may be used by notification routing or operator displays. // 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; } public string? LoLoMessage { get; set; }
/// <summary>
/// The operator message for low alarm state.
/// </summary>
public string? LoMessage { get; set; } public string? LoMessage { get; set; }
/// <summary>
/// The operator message for high alarm state.
/// </summary>
public string? HiMessage { get; set; } public string? HiMessage { get; set; }
/// <summary>
/// The operator message for high-high alarm state.
/// </summary>
public string? HiHiMessage { get; set; } public string? HiHiMessage { get; set; }
// Expression — boolean C# expression evaluated on attribute updates. // Expression — boolean C# expression evaluated on attribute updates.
/// <summary>
/// The boolean C# expression to evaluate for Expression triggers.
/// </summary>
public string? Expression { get; set; } public string? Expression { get; set; }
} }
@@ -32,6 +32,7 @@ public class DialogService : IDialogService
// (the Blazor renderer's, for an event-handler caller). // (the Blazor renderer's, for an event-handler caller).
private TaskCompletionSource<object?>? _tcs; private TaskCompletionSource<object?>? _tcs;
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false) public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
{ {
EnsureNoActiveDialog(); EnsureNoActiveDialog();
@@ -42,6 +43,7 @@ public class DialogService : IDialogService
return Project(tcs.Task, static r => r is bool b && b); 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) public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
{ {
EnsureNoActiveDialog(); EnsureNoActiveDialog();
@@ -68,6 +70,7 @@ public class DialogService : IDialogService
/// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms /// dialog. <paramref name="result"/> must be a <c>bool</c> for confirms
/// and a <c>string?</c> for prompts (null = cancel). /// and a <c>string?</c> for prompts (null = cancel).
/// </summary> /// </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) internal void Resolve(object? result)
{ {
var tcs = _tcs; var tcs = _tcs;
@@ -17,6 +17,7 @@ internal static class DurationInput
/// A null or non-positive duration yields a blank value and the default /// A null or non-positive duration yields a blank value and the default
/// <c>sec</c> unit. /// <c>sec</c> unit.
/// </summary> /// </summary>
/// <param name="duration">The duration to split, or null for unset.</param>
internal static (string? Value, string Unit) Split(TimeSpan? duration) internal static (string? Value, string Unit) Split(TimeSpan? duration)
{ {
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec"); 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 /// Composes a number+unit pair into a duration. A blank, unparseable, or
/// non-positive value yields <c>null</c> (unset). /// non-positive value yields <c>null</c> (unset).
/// </summary> /// </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) internal static TimeSpan? Compose(string? value, string unit)
{ {
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) 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 /// <paramref name="radius"/> is how many pages to show on each side of the
/// current page. /// current page.
/// </summary> /// </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) public static IReadOnlyList<int> Build(int currentPage, int totalPages, int radius = 2)
{ {
if (totalPages <= 1) if (totalPages <= 1)
@@ -24,8 +24,11 @@ internal sealed class SchemaProperty
{ {
/// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary> /// <summary>Stable identity for Blazor <c>@key</c> across renames.</summary>
public Guid Id { get; } = Guid.NewGuid(); 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; 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; 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(); 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 /// shape (<c>[{name,type,required,itemType?}]</c>) for safety during the
/// transition window — translates it into an equivalent object schema. /// transition window — translates it into an equivalent object schema.
/// </summary> /// </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) public static SchemaNode Parse(string? json, SchemaNode fallback)
{ {
if (string.IsNullOrWhiteSpace(json)) return fallback; if (string.IsNullOrWhiteSpace(json)) return fallback;
@@ -66,6 +71,10 @@ internal static class SchemaBuilderModel
/// <summary>Default scalar schema (return mode default).</summary> /// <summary>Default scalar schema (return mode default).</summary>
public static SchemaNode NewValue() => new() { Type = "string" }; 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) public static string Serialize(SchemaNode node)
{ {
using var stream = new System.IO.MemoryStream(); using var stream = new System.IO.MemoryStream();
@@ -9,12 +9,20 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// </summary> /// </summary>
public static class ScriptParameterNames 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) => public static IReadOnlyList<string> Parse(string? json) =>
JsonSchemaShapeParser.ParseParameters(json) JsonSchemaShapeParser.ParseParameters(json)
.Select(p => p.Name) .Select(p => p.Name)
.Where(s => !string.IsNullOrEmpty(s)) .Where(s => !string.IsNullOrEmpty(s))
.ToList(); .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) => public static IReadOnlyList<ParameterShape> ParseShapes(string? json) =>
JsonSchemaShapeParser.ParseParameters(json); JsonSchemaShapeParser.ParseParameters(json);
} }
@@ -67,6 +67,7 @@ internal static class ScriptTriggerConfigCodec
internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" }; internal static readonly string[] Operators = { ">", ">=", "<", "<=", "==", "!=" };
/// <summary>Classifies a raw <c>TriggerType</c> string (case-insensitive).</summary> /// <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) internal static ScriptTriggerKind ParseKind(string? triggerType)
{ {
if (string.IsNullOrWhiteSpace(triggerType)) return ScriptTriggerKind.None; 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 /// Expression. False for Interval (its own period is the cadence), Call
/// (invoked explicitly, never throttled), and None/Unknown. /// (invoked explicitly, never throttled), and None/Unknown.
/// </summary> /// </summary>
/// <param name="triggerType">The raw trigger type string to classify.</param>
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) => internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
ParseKind(triggerType) is ScriptTriggerKind.ValueChange ParseKind(triggerType) is ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Conditional
or ScriptTriggerKind.Expression; or ScriptTriggerKind.Expression;
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary> /// <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 internal static string? KindToString(ScriptTriggerKind kind) => kind switch
{ {
ScriptTriggerKind.Interval => "Interval", ScriptTriggerKind.Interval => "Interval",
@@ -108,6 +111,8 @@ internal static class ScriptTriggerConfigCodec
/// Returns a model with default values on null/empty/malformed input or for /// Returns a model with default values on null/empty/malformed input or for
/// missing keys — never throws. /// missing keys — never throws.
/// </summary> /// </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) internal static ScriptTriggerModel Parse(string? json, ScriptTriggerKind kind)
{ {
var model = new ScriptTriggerModel(); var model = new ScriptTriggerModel();
@@ -154,6 +159,8 @@ internal static class ScriptTriggerConfigCodec
/// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c> /// Serializes the model to the JSON shape <c>ScriptActor.ParseTriggerConfig</c>
/// expects. Returns null for None/Unknown (no structured config to emit). /// expects. Returns null for None/Unknown (no structured config to emit).
/// </summary> /// </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) internal static string? Serialize(ScriptTriggerModel model, ScriptTriggerKind kind)
{ {
if (kind is ScriptTriggerKind.None or ScriptTriggerKind.Unknown) return null; 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> /// <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) internal static string NormalizeOperator(string? raw)
{ {
var op = raw?.Trim(); var op = raw?.Trim();
@@ -22,9 +22,13 @@ public enum TemplateTreeNodeKind
/// </summary> /// </summary>
public sealed class TemplateTreeNode public sealed class TemplateTreeNode
{ {
/// <summary>Discriminator indicating whether this node represents a folder, template, or composition slot.</summary>
public required TemplateTreeNodeKind Kind { get; init; } public required TemplateTreeNodeKind Kind { get; init; }
/// <summary>Database id of the underlying folder, template, or composition record.</summary>
public required int Id { get; init; } public required int Id { get; init; }
/// <summary>Display name of the node.</summary>
public required string Name { get; init; } public required string Name { get; init; }
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
public List<TemplateTreeNode> Children { get; } = new(); public List<TemplateTreeNode> Children { get; } = new();
/// <summary>Stable key for TreeView selection / expansion tracking.</summary> /// <summary>Stable key for TreeView selection / expansion tracking.</summary>
@@ -18,6 +18,7 @@ namespace ScadaLink.CentralUI.Components.Shared;
public static class TriggerAttributeMapper public static class TriggerAttributeMapper
{ {
/// <summary>Direct and inherited attributes, exposed as <c>Attributes["..."]</c>.</summary> /// <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( public static IReadOnlyList<AttributeShape> SelfAttributes(
IReadOnlyList<AlarmAttributeChoice> choices) => IReadOnlyList<AlarmAttributeChoice> choices) =>
choices choices
@@ -30,6 +31,7 @@ public static class TriggerAttributeMapper
/// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix /// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix
/// are skipped (no child scope to attach them to). /// are skipped (no child scope to attach them to).
/// </summary> /// </summary>
/// <param name="choices">The full flattened attribute choice list from the trigger editor.</param>
public static IReadOnlyList<CompositionContext> Children( public static IReadOnlyList<CompositionContext> Children(
IReadOnlyList<AlarmAttributeChoice> choices) => IReadOnlyList<AlarmAttributeChoice> choices) =>
choices choices
@@ -13,6 +13,8 @@ public static class EndpointExtensions
/// Maps the Central UI endpoints. The caller must provide the root App component type /// 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. /// from the Host assembly so that blazor.web.js is served correctly.
/// </summary> /// </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) public static IEndpointRouteBuilder MapCentralUI<TApp>(this IEndpointRouteBuilder endpoints)
where TApp : Microsoft.AspNetCore.Components.IComponent where TApp : Microsoft.AspNetCore.Components.IComponent
{ {
@@ -8,6 +8,7 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public interface ISharedScriptCatalog public interface ISharedScriptCatalog
{ {
/// <summary>Returns the parameter and return shapes for all registered shared scripts.</summary>
Task<IReadOnlyList<ScriptShape>> GetShapesAsync(); Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
/// <summary> /// <summary>
@@ -15,6 +16,8 @@ public interface ISharedScriptCatalog
/// null if no shared script with that name exists. Used by Test Run to /// null if no shared script with that name exists. Used by Test Run to
/// compile and execute nested CallShared invocations. /// compile and execute nested CallShared invocations.
/// </summary> /// </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); Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
} }
@@ -24,8 +27,13 @@ public class SharedScriptCatalog : ISharedScriptCatalog
{ {
private readonly SharedScriptService _service; 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; public SharedScriptCatalog(SharedScriptService service) => _service = service;
/// <inheritdoc />
public async Task<IReadOnlyList<ScriptShape>> GetShapesAsync() public async Task<IReadOnlyList<ScriptShape>> GetShapesAsync()
{ {
var scripts = await _service.GetAllSharedScriptsAsync(); var scripts = await _service.GetAllSharedScriptsAsync();
@@ -34,6 +42,7 @@ public class SharedScriptCatalog : ISharedScriptCatalog
.ToList(); .ToList();
} }
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default) public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrEmpty(name)) return null; if (string.IsNullOrEmpty(name)) return null;
@@ -10,44 +10,84 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public class InboundScriptHost public class InboundScriptHost
{ {
/// <summary>
/// The request parameters passed to the inbound API method.
/// </summary>
public ScriptParameters Parameters { get; init; } = new(); public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// The route helper for accessing target instances.
/// </summary>
public RouteHelper Route { get; } = new(); public RouteHelper Route { get; } = new();
/// <summary>
/// The cancellation token for the operation.
/// </summary>
public System.Threading.CancellationToken CancellationToken { get; } public System.Threading.CancellationToken CancellationToken { get; }
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary> /// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteHelper 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(); public RouteTarget To(string instanceCode) => new();
} }
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary> /// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary>
public class RouteTarget 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( public System.Threading.Tasks.Task<object?> Call(
string scriptName, string scriptName,
object? parameters = null, object? parameters = null,
System.Threading.CancellationToken cancellationToken = default) => System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null); 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( public System.Threading.Tasks.Task<object?> GetAttribute(
string attributeName, string attributeName,
System.Threading.CancellationToken cancellationToken = default) => System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null); 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( public System.Threading.Tasks.Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames, IEnumerable<string> attributeNames,
System.Threading.CancellationToken cancellationToken = default) => System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>( System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>(
new Dictionary<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( public System.Threading.Tasks.Task SetAttribute(
string attributeName, string attributeName,
string value, string value,
System.Threading.CancellationToken cancellationToken = default) => System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask; 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( public System.Threading.Tasks.Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues, IReadOnlyDictionary<string, string> attributeValues,
System.Threading.CancellationToken cancellationToken = default) => System.Threading.CancellationToken cancellationToken = default) =>
@@ -18,6 +18,8 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public static class JsonSchemaShapeParser 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) public static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>(); 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) public static string? ParseReturnType(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) return null; if (string.IsNullOrWhiteSpace(json)) return null;
@@ -33,6 +33,7 @@ internal sealed class SandboxConsoleCapture : TextWriter
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback; private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
/// <inheritdoc />
public override Encoding Encoding => _fallback.Encoding; public override Encoding Encoding => _fallback.Encoding;
/// <summary> /// <summary>
@@ -70,6 +71,7 @@ internal sealed class SandboxConsoleCapture : TextWriter
/// The scope is restored on dispose, so nesting and concurrent scopes on /// The scope is restored on dispose, so nesting and concurrent scopes on
/// other call-trees are unaffected. /// other call-trees are unaffected.
/// </summary> /// </summary>
/// <param name="buffer">The writer that receives console output for this scope.</param>
public CaptureScope BeginCapture(StringWriter buffer) public CaptureScope BeginCapture(StringWriter buffer)
{ {
var previous = _current.Value; var previous = _current.Value;
@@ -77,15 +79,20 @@ internal sealed class SandboxConsoleCapture : TextWriter
return new CaptureScope(this, previous); return new CaptureScope(this, previous);
} }
/// <inheritdoc />
public override void Write(char value) => Target.Write(value); public override void Write(char value) => Target.Write(value);
/// <inheritdoc />
public override void Write(string? value) => Target.Write(value); public override void Write(string? value) => Target.Write(value);
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) => public override void Write(char[] buffer, int index, int count) =>
Target.Write(buffer, index, count); Target.Write(buffer, index, count);
/// <inheritdoc />
public override void WriteLine() => Target.WriteLine(); public override void WriteLine() => Target.WriteLine();
/// <inheritdoc />
public override void WriteLine(string? value) => Target.WriteLine(value); public override void WriteLine(string? value) => Target.WriteLine(value);
private TextWriter Target => _current.Value ?? _fallback; private TextWriter Target => _current.Value ?? _fallback;
@@ -95,12 +102,18 @@ internal sealed class SandboxConsoleCapture : TextWriter
private readonly SandboxConsoleCapture _owner; private readonly SandboxConsoleCapture _owner;
private readonly StringWriter? _previous; 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) internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
{ {
_owner = owner; _owner = owner;
_previous = previous; _previous = previous;
} }
/// <summary>Restores the previous capture scope.</summary>
public void Dispose() => _owner._current.Value = _previous; public void Dispose() => _owner._current.Value = _previous;
} }
} }
@@ -18,12 +18,20 @@ public class SandboxExternalHelper
private readonly IExternalSystemClient? _client; private readonly IExternalSystemClient? _client;
private readonly string _instanceName; 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) public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
{ {
_client = client; _client = client;
_instanceName = instanceName; _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( public Task<ExternalCallResult> Call(
string systemName, string systemName,
string methodName, string methodName,
@@ -36,6 +44,11 @@ public class SandboxExternalHelper
return _client.CallAsync(systemName, methodName, parameters, cancellationToken); 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( public Task<ExternalCallResult> CachedCall(
string systemName, string systemName,
string methodName, string methodName,
@@ -49,17 +62,24 @@ public class SandboxExternalHelper
} }
} }
/// <summary>Sandbox database helper for script analysis.</summary>
public class SandboxDatabaseHelper public class SandboxDatabaseHelper
{ {
private readonly IDatabaseGateway? _gateway; private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName; 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) public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName)
{ {
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _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) public Task<DbConnection> Connection(string name, CancellationToken cancellationToken = default)
{ {
if (_gateway == null) if (_gateway == null)
@@ -68,6 +88,11 @@ public class SandboxDatabaseHelper
return _gateway.GetConnectionAsync(name, cancellationToken); 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( public Task CachedWrite(
string name, string name,
string sql, string sql,
@@ -97,6 +122,7 @@ public class SandboxDatabaseHelper
public class SandboxNotifyHelper public class SandboxNotifyHelper
{ {
/// <summary>Selects the notification list to send to.</summary> /// <summary>Selects the notification list to send to.</summary>
/// <param name="listName">The notification list name.</param>
public SandboxNotifyTarget To(string listName) => public SandboxNotifyTarget To(string listName) =>
new(); new();
@@ -106,6 +132,7 @@ public class SandboxNotifyHelper
/// <c>Unknown</c> status — it exists for signature fidelity with /// <c>Unknown</c> status — it exists for signature fidelity with
/// <c>NotifyHelper.Status</c>. /// <c>NotifyHelper.Status</c>.
/// </summary> /// </summary>
/// <param name="notificationId">The notification ID to check status for.</param>
public Task<NotificationDeliveryStatus> Status(string notificationId) => public Task<NotificationDeliveryStatus> Status(string notificationId) =>
Task.FromResult(new NotificationDeliveryStatus("Unknown", 0, null, null)); Task.FromResult(new NotificationDeliveryStatus("Unknown", 0, null, null));
} }
@@ -116,6 +143,7 @@ public class SandboxNotifyHelper
/// </summary> /// </summary>
public class SandboxNotifyTarget public class SandboxNotifyTarget
{ {
/// <summary>Initializes a new instance of the SandboxNotifyTarget class.</summary>
internal SandboxNotifyTarget() internal SandboxNotifyTarget()
{ {
} }
@@ -125,6 +153,9 @@ public class SandboxNotifyTarget
/// the sandbox nothing is enqueued or delivered; a fake id is returned so /// the sandbox nothing is enqueued or delivered; a fake id is returned so
/// the call type-checks identically to production. /// the call type-checks identically to production.
/// </summary> /// </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) => public Task<string> Send(string subject, string message, CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N")); Task.FromResult(Guid.NewGuid().ToString("N"));
} }
@@ -14,15 +14,22 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public class SandboxInboundScriptHost public class SandboxInboundScriptHost
{ {
/// <summary>Gets or initializes the script input parameters.</summary>
public ScriptParameters Parameters { get; init; } = new(); public ScriptParameters Parameters { get; init; } = new();
/// <summary>Gets or initializes the cancellation token for the test run.</summary>
public CancellationToken CancellationToken { get; init; } 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(); public RouteAccessor Route { get; } = new();
/// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary> /// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
public class RouteAccessor 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); public RouteTarget To(string instanceCode) => new(instanceCode);
} }
@@ -31,30 +38,61 @@ public class SandboxInboundScriptHost
{ {
private readonly string _instanceCode; 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; 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( public Task<object?> Call(
string scriptName, string scriptName,
object? parameters = null, object? parameters = null,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
throw Unavailable($"Call(\"{scriptName}\")"); 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( public Task<object?> GetAttribute(
string attributeName, string attributeName,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
throw Unavailable($"GetAttribute(\"{attributeName}\")"); 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( public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames, IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
throw Unavailable("GetAttributes(...)"); 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( public Task SetAttribute(
string attributeName, string attributeName,
string value, string value,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
throw Unavailable($"SetAttribute(\"{attributeName}\")"); 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( public Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues, IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
@@ -17,6 +17,13 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
private readonly string _instanceUniqueName; private readonly string _instanceUniqueName;
private readonly CancellationToken _runToken; 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( public SandboxInstanceGateway(
CommunicationService comms, CommunicationService comms,
string siteId, string siteId,
@@ -29,6 +36,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
_runToken = runToken; _runToken = runToken;
} }
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct) public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
{ {
var request = new RouteToGetAttributesRequest( var request = new RouteToGetAttributesRequest(
@@ -41,6 +49,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
return response.Values.TryGetValue(canonicalName, out var value) ? value : null; return response.Values.TryGetValue(canonicalName, out var value) ? value : null;
} }
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct)
{ {
var request = new RouteToSetAttributesRequest( var request = new RouteToSetAttributesRequest(
@@ -52,6 +61,7 @@ public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
$"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}"); $"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
} }
/// <inheritdoc />
public async Task<object?> CallScriptAsync( public async Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct) string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
{ {
@@ -19,23 +19,64 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public class SandboxScriptHost public class SandboxScriptHost
{ {
/// <summary>
/// Script parameters passed to the sandbox.
/// </summary>
public ScriptParameters Parameters { get; init; } = new(); public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// Cancellation token for the sandbox execution.
/// </summary>
public CancellationToken CancellationToken { get; init; } public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Alarm context for the sandbox.
/// </summary>
public AlarmContext? Alarm { get; init; } public AlarmContext? Alarm { get; init; }
/// <summary>
/// Script scope defining the execution context.
/// </summary>
public ScriptScope Scope { get; init; } = ScriptScope.Root; public ScriptScope Scope { get; init; } = ScriptScope.Root;
/// <summary>
/// Instance context providing access to deployed instance data.
/// </summary>
public SandboxInstanceContext Instance { get; init; } = new(); public SandboxInstanceContext Instance { get; init; } = new();
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem; public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database => Instance.Database; public SandboxDatabaseHelper Database => Instance.Database;
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify => Instance.Notify; public SandboxNotifyHelper Notify => Instance.Notify;
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts => Instance.Scripts; public SandboxScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Accessor for attributes scoped to the current instance.
/// </summary>
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath); public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for child compositions.
/// </summary>
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath); public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for the parent composition, or null if at root.
/// </summary>
public SandboxCompositionAccessor? Parent => public SandboxCompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath); Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath);
} }
@@ -47,8 +88,29 @@ public class SandboxScriptHost
/// </summary> /// </summary>
public interface ISandboxInstanceGateway 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); 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); 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( Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct); string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
} }
@@ -65,11 +127,34 @@ public class SandboxInstanceContext
{ {
private readonly ISandboxInstanceGateway? _gateway; private readonly ISandboxInstanceGateway? _gateway;
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem { get; } public SandboxExternalHelper ExternalSystem { get; }
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database { get; } public SandboxDatabaseHelper Database { get; }
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify { get; } public SandboxNotifyHelper Notify { get; }
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts { get; } 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( public SandboxInstanceContext(
ISandboxInstanceGateway? gateway = null, ISandboxInstanceGateway? gateway = null,
SandboxExternalHelper? external = null, SandboxExternalHelper? external = null,
@@ -84,6 +169,11 @@ public class SandboxInstanceContext
Scripts = scripts ?? new SandboxScriptCallHelper(null); 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) public Task<object?> GetAttribute(string attributeName)
{ {
if (_gateway == null) if (_gateway == null)
@@ -93,6 +183,11 @@ public class SandboxInstanceContext
return _gateway.GetAttributeAsync(attributeName, CancellationToken.None); 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) public void SetAttribute(string attributeName, string value)
{ {
if (_gateway == null) if (_gateway == null)
@@ -102,6 +197,12 @@ public class SandboxInstanceContext
_gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult(); _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) public Task<object?> CallScript(string scriptName, object? parameters = null)
{ {
if (_gateway == null) if (_gateway == null)
@@ -121,12 +222,23 @@ public class SandboxScriptCallHelper
{ {
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared; 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( public SandboxScriptCallHelper(
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared) Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
{ {
_callShared = 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( public Task<object?> CallShared(
string scriptName, string scriptName,
object? parameters = null, object? parameters = null,
@@ -147,25 +259,54 @@ public class SandboxAttributeAccessor
{ {
private readonly SandboxInstanceContext _ctx; private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The scope prefix for attribute resolution.
/// </summary>
public string ScopePrefix { get; } 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) public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
{ {
_ctx = ctx; _ctx = ctx;
ScopePrefix = prefix; 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) => public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + 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] public object? this[string key]
{ {
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult(); get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty); 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)); 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) public Task SetAsync(string key, object? value)
{ {
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty); _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
@@ -181,10 +322,21 @@ public class SandboxCompositionAccessor
{ {
private readonly SandboxInstanceContext _ctx; private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The path to the composition within the instance hierarchy.
/// </summary>
public string Path { get; } public string Path { get; }
/// <summary>
/// Accessor for attributes within the composition.
/// </summary>
public SandboxAttributeAccessor Attributes { get; } 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) public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
{ {
_ctx = ctx; _ctx = ctx;
@@ -192,9 +344,20 @@ public class SandboxCompositionAccessor
Attributes = new SandboxAttributeAccessor(ctx, path); 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) => public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + 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) public Task<object?> CallScript(string scriptName, object? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters); => _ctx.CallScript(ResolveScript(scriptName), parameters);
} }
@@ -208,12 +371,22 @@ public class SandboxChildrenAccessor
private readonly SandboxInstanceContext _ctx; private readonly SandboxInstanceContext _ctx;
private readonly string _selfPath; 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) public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
{ {
_ctx = ctx; _ctx = ctx;
_selfPath = selfPath; _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] public SandboxCompositionAccessor this[string compositionName]
{ {
get get
@@ -232,5 +405,9 @@ public class SandboxChildrenAccessor
/// </summary> /// </summary>
public class ScriptSandboxException : Exception 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) { } public ScriptSandboxException(string message) : base(message) { }
} }
@@ -6,8 +6,14 @@ using ScadaLink.Security;
namespace ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Minimal-API endpoint group for Roslyn-backed script analysis (diagnostics, completions, hover, etc.).
/// </summary>
public static class ScriptAnalysisEndpoints 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) public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{ {
var group = endpoints.MapGroup("/api/script-analysis") var group = endpoints.MapGroup("/api/script-analysis")
@@ -72,6 +72,10 @@ public class ScriptAnalysisService
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IServiceProvider _services; 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( public ScriptAnalysisService(
ISharedScriptCatalog sharedScripts, ISharedScriptCatalog sharedScripts,
IMemoryCache cache, IMemoryCache cache,
@@ -97,6 +101,9 @@ public class ScriptAnalysisService
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations)) ? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
: compilation; : 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) public DiagnoseResponse Diagnose(DiagnoseRequest request)
{ {
if (string.IsNullOrEmpty(request.Code)) if (string.IsNullOrEmpty(request.Code))
@@ -171,6 +178,9 @@ public class ScriptAnalysisService
/// land in the result without mutating process-global Console state — two /// land in the result without mutating process-global Console state — two
/// concurrent Test Runs do not interfere with each other. /// concurrent Test Runs do not interfere with each other.
/// </summary> /// </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) public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(request.Code)) if (string.IsNullOrWhiteSpace(request.Code))
@@ -519,6 +529,9 @@ public class ScriptAnalysisService
return Convert.ToHexString(bytes); 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) public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
{ {
if (string.IsNullOrEmpty(request.CodeText)) if (string.IsNullOrEmpty(request.CodeText))
@@ -696,6 +709,9 @@ public class ScriptAnalysisService
InsertTextRules: insertAsSnippet); 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) public FormatResponse Format(FormatRequest request)
{ {
if (string.IsNullOrEmpty(request.Code)) if (string.IsNullOrEmpty(request.Code))
@@ -723,9 +739,14 @@ public class ScriptAnalysisService
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is /// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
/// already self-labelling — there are no positional arguments to annotate. /// already self-labelling — there are no positional arguments to annotate.
/// </summary> /// </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) => public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>()); 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) public async Task<HoverResponse> Hover(HoverRequest request)
{ {
var script = TryParse(request.CodeText); var script = TryParse(request.CodeText);
@@ -772,6 +793,9 @@ public class ScriptAnalysisService
return new HoverResponse(FormatHover(shape, call)); 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) public async Task<SignatureHelpResponse> SignatureHelp(SignatureHelpRequest request)
{ {
var empty = new SignatureHelpResponse(null, null, 0); var empty = new SignatureHelpResponse(null, null, 0);
@@ -8,6 +8,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// </summary> /// </summary>
public static class ScriptShapeParser 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) public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
{ {
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson); var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
@@ -10,6 +10,10 @@ namespace ScadaLink.CentralUI;
public static class ServiceCollectionExtensions 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) public static IServiceCollection AddCentralUI(this IServiceCollection services)
{ {
services.AddRazorComponents() services.AddRazorComponents()
@@ -59,6 +59,10 @@ public sealed class AuditLogExportService : IAuditLogExportService
private readonly IAuditLogRepository _repository; 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) public AuditLogExportService(IAuditLogRepository repository)
{ {
_repository = repository ?? throw new ArgumentNullException(nameof(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 /// scalars use invariant culture so an export taken on one locale parses
/// cleanly on another. /// cleanly on another.
/// </summary> /// </summary>
/// <param name="evt">The audit event to format as a CSV row.</param>
internal static string FormatCsvRow(AuditEvent evt) internal static string FormatCsvRow(AuditEvent evt)
{ {
var sb = new StringBuilder(256); var sb = new StringBuilder(256);
@@ -65,6 +65,8 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// <c>ScadaLinkDbContext</c> and never contends with the circuit-scoped /// <c>ScadaLinkDbContext</c> and never contends with the circuit-scoped
/// context the filter bar uses. /// context the filter bar uses.
/// </summary> /// </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( public AuditLogQueryService(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator) ICentralHealthAggregator healthAggregator)
@@ -77,6 +79,8 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// Test-seam constructor — injects a repository instance whose lifetime the /// Test-seam constructor — injects a repository instance whose lifetime the
/// caller owns. Used by unit tests that substitute a stub repository. /// caller owns. Used by unit tests that substitute a stub repository.
/// </summary> /// </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( public AuditLogQueryService(
IAuditLogRepository repository, IAuditLogRepository repository,
ICentralHealthAggregator healthAggregator) ICentralHealthAggregator healthAggregator)
@@ -85,8 +89,10 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator)); _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
} }
/// <inheritdoc />
public int DefaultPageSize => 100; public int DefaultPageSize => 100;
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> QueryAsync( public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogQueryFilter filter,
AuditLogPaging? paging = null, AuditLogPaging? paging = null,
@@ -21,6 +21,9 @@ public interface IAuditLogQueryService
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/> /// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
/// back as the cursor for the next page. /// back as the cursor for the next page.
/// </summary> /// </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( Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogQueryFilter filter,
AuditLogPaging? paging = null, AuditLogPaging? paging = null,
@@ -49,6 +52,7 @@ public interface IAuditLogQueryService
/// outage degrades the tile group to "unavailable" rather than killing the /// outage degrades the tile group to "unavailable" rather than killing the
/// dashboard. /// dashboard.
/// </remarks> /// </remarks>
/// <param name="ct">Cancellation token.</param>
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default); Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
/// <summary> /// <summary>
@@ -66,6 +70,8 @@ public interface IAuditLogQueryService
/// implementation opens its own DI scope per call so the tree page's /// implementation opens its own DI scope per call so the tree page's
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>. /// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>.
/// </remarks> /// </remarks>
/// <param name="executionId">Any execution id in the chain to look up.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync( Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, Guid executionId,
CancellationToken ct = default); CancellationToken ct = default);
@@ -79,5 +85,6 @@ public interface IAuditLogQueryService
/// (failover, scaling) surface within a minute, which is acceptable for a /// (failover, scaling) surface within a minute, which is acceptable for a
/// filter affordance. /// filter affordance.
/// </summary> /// </summary>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default); Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
} }
@@ -18,6 +18,11 @@ public sealed class ClusterOptionsValidator : IValidateOptions<ClusterOptions>
"keep-oldest" "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) public ValidateOptionsResult Validate(string? name, ClusterOptions options)
{ {
var failures = new List<string>(); var failures = new List<string>();
@@ -22,6 +22,7 @@ public static class ServiceCollectionExtensions
/// into a broken cluster. /// into a broken cluster.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services) public static IServiceCollection AddClusterInfrastructure(this IServiceCollection services)
{ {
services.TryAddEnumerable( services.TryAddEnumerable(
@@ -37,6 +38,7 @@ public static class ServiceCollectionExtensions
/// fast with a clear cause instead of failing later, far from here. /// fast with a clear cause instead of failing later, far from here.
/// </summary> /// </summary>
/// <exception cref="NotImplementedException">Always thrown.</exception> /// <exception cref="NotImplementedException">Always thrown.</exception>
/// <param name="services">The service collection (unused; method always throws).</param>
public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services) public static IServiceCollection AddClusterInfrastructureActors(this IServiceCollection services)
{ {
throw new NotImplementedException( throw new NotImplementedException(
@@ -2,16 +2,33 @@ namespace ScadaLink.Commons.Entities.Audit;
public class AuditLogEntry public class AuditLogEntry
{ {
/// <summary>Auto-incremented primary key.</summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>Username of the actor who performed the action.</summary>
public string User { get; set; } public string User { get; set; }
/// <summary>Action performed (e.g. Created, Updated, Deleted).</summary>
public string Action { get; set; } public string Action { get; set; }
/// <summary>Entity type name (e.g. Template, ExternalSystem).</summary>
public string EntityType { get; set; } public string EntityType { get; set; }
/// <summary>String representation of the entity's primary key.</summary>
public string EntityId { get; set; } public string EntityId { get; set; }
/// <summary>Human-readable name of the affected entity.</summary>
public string EntityName { get; set; } 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; } public string? AfterStateJson { get; set; }
/// <summary>UTC timestamp when the audit entry was recorded.</summary>
public DateTimeOffset Timestamp { get; set; } 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; } 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) public AuditLogEntry(string user, string action, string entityType, string entityId, string entityName)
{ {
User = user ?? throw new ArgumentNullException(nameof(user)); User = user ?? throw new ArgumentNullException(nameof(user));
@@ -6,9 +6,13 @@ namespace ScadaLink.Commons.Entities.Deployment;
/// </summary> /// </summary>
public class DeployedConfigSnapshot public class DeployedConfigSnapshot
{ {
/// <summary>Primary key.</summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>Foreign key to the owning <c>Instance</c> entity.</summary>
public int InstanceId { get; set; } public int InstanceId { get; set; }
/// <summary>Unique deployment identifier assigned at deploy time for idempotency.</summary>
public string DeploymentId { get; set; } 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; } public string RevisionHash { get; set; }
/// <summary> /// <summary>
@@ -16,8 +20,13 @@ public class DeployedConfigSnapshot
/// </summary> /// </summary>
public string ConfigurationJson { get; set; } public string ConfigurationJson { get; set; }
/// <summary>UTC timestamp when this snapshot was persisted.</summary>
public DateTimeOffset DeployedAt { get; set; } 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) public DeployedConfigSnapshot(string deploymentId, string revisionHash, string configurationJson)
{ {
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId)); DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
@@ -4,14 +4,49 @@ namespace ScadaLink.Commons.Entities.Deployment;
public class DeploymentRecord public class DeploymentRecord
{ {
/// <summary>
/// The deployment record identifier.
/// </summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>
/// The instance identifier being deployed.
/// </summary>
public int InstanceId { get; set; } public int InstanceId { get; set; }
/// <summary>
/// The current deployment status.
/// </summary>
public DeploymentStatus Status { get; set; } public DeploymentStatus Status { get; set; }
/// <summary>
/// The deployment identifier.
/// </summary>
public string DeploymentId { get; set; } public string DeploymentId { get; set; }
/// <summary>
/// The revision hash of the deployed configuration, or null.
/// </summary>
public string? RevisionHash { get; set; } public string? RevisionHash { get; set; }
/// <summary>
/// The user who initiated the deployment.
/// </summary>
public string DeployedBy { get; set; } public string DeployedBy { get; set; }
/// <summary>
/// The time when the deployment was initiated.
/// </summary>
public DateTimeOffset DeployedAt { get; set; } public DateTimeOffset DeployedAt { get; set; }
/// <summary>
/// The time when the deployment completed, or null if still in progress.
/// </summary>
public DateTimeOffset? CompletedAt { get; set; } public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Error message if the deployment failed, or null.
/// </summary>
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
/// <summary> /// <summary>
@@ -19,6 +54,11 @@ public class DeploymentRecord
/// </summary> /// </summary>
public byte[] RowVersion { get; set; } = []; 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) public DeploymentRecord(string deploymentId, string deployedBy)
{ {
DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId)); DeploymentId = deploymentId ?? throw new ArgumentNullException(nameof(deploymentId));
@@ -1,13 +1,24 @@
namespace ScadaLink.Commons.Entities.Deployment; namespace ScadaLink.Commons.Entities.Deployment;
/// <summary>
/// Records a system-wide artifact deployment operation, tracking status per site.
/// </summary>
public class SystemArtifactDeploymentRecord public class SystemArtifactDeploymentRecord
{ {
/// <summary>Primary key.</summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>Type identifier for the deployed artifact (e.g. the artifact category name).</summary>
public string ArtifactType { get; set; } public string ArtifactType { get; set; }
/// <summary>Username of the operator who initiated the deployment.</summary>
public string DeployedBy { get; set; } public string DeployedBy { get; set; }
/// <summary>UTC timestamp when the deployment was initiated.</summary>
public DateTimeOffset DeployedAt { get; set; } 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; } 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) public SystemArtifactDeploymentRecord(string artifactType, string deployedBy)
{ {
ArtifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType)); ArtifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
@@ -2,12 +2,20 @@ namespace ScadaLink.Commons.Entities.ExternalSystems;
public class DatabaseConnectionDefinition public class DatabaseConnectionDefinition
{ {
/// <summary>Gets or sets the primary key.</summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>Gets or sets the human-readable connection name.</summary>
public string Name { get; set; } public string Name { get; set; }
/// <summary>Gets or sets the ADO.NET connection string for this database.</summary>
public string ConnectionString { get; set; } public string ConnectionString { get; set; }
/// <summary>Gets or sets the maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; } public int MaxRetries { get; set; }
/// <summary>Gets or sets the delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; } 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) public DatabaseConnectionDefinition(string name, string connectionString)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); Name = name ?? throw new ArgumentNullException(nameof(name));
@@ -2,14 +2,27 @@ namespace ScadaLink.Commons.Entities.ExternalSystems;
public class ExternalSystemDefinition public class ExternalSystemDefinition
{ {
/// <summary>Database primary key.</summary>
public int Id { get; set; } public int Id { get; set; }
/// <summary>Display name for the external system.</summary>
public string Name { get; set; } public string Name { get; set; }
/// <summary>Base URL of the external system's HTTP endpoint.</summary>
public string EndpointUrl { get; set; } public string EndpointUrl { get; set; }
/// <summary>Authentication type identifier (e.g., "ApiKey", "Basic").</summary>
public string AuthType { get; set; } public string AuthType { get; set; }
/// <summary>JSON-serialized authentication configuration for the selected <see cref="AuthType"/>.</summary>
public string? AuthConfiguration { get; set; } public string? AuthConfiguration { get; set; }
/// <summary>Maximum number of retry attempts for transient failures.</summary>
public int MaxRetries { get; set; } public int MaxRetries { get; set; }
/// <summary>Fixed delay between retry attempts.</summary>
public TimeSpan RetryDelay { get; set; } 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) public ExternalSystemDefinition(string name, string endpointUrl, string authType)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); Name = name ?? throw new ArgumentNullException(nameof(name));

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