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