refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Keyset paging cursor for <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||
/// The repository orders by <c>(OccurredAtUtc DESC, EventId DESC)</c>; callers pass
|
||||
/// the last row of the previous page back as <see cref="AfterOccurredAtUtc"/> +
|
||||
/// <see cref="AfterEventId"/> to fetch the next page. Both must be non-null together,
|
||||
/// or both null (first page).
|
||||
/// </summary>
|
||||
public sealed record AuditLogPaging(
|
||||
int PageSize,
|
||||
DateTime? AfterOccurredAtUtc = null,
|
||||
Guid? AfterEventId = null);
|
||||
@@ -0,0 +1,38 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filter predicate for <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||
/// Any field left <c>null</c> means "do not constrain on that column". The
|
||||
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/>,
|
||||
/// <see cref="SourceSiteIds"/> and <see cref="SourceNodes"/> dimensions are
|
||||
/// multi-value: a <c>null</c> OR empty list means "do not constrain", and a
|
||||
/// non-empty list is OR-combined within the dimension (translated to a SQL
|
||||
/// <c>IN (…)</c>). Time bounds are half-open in the spec sense —
|
||||
/// <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is inclusive of
|
||||
/// the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
||||
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
||||
/// </summary>
|
||||
/// <param name="SourceNodes">
|
||||
/// Restrict to rows whose <c>SourceNode</c> matches one of the supplied node
|
||||
/// identifiers (e.g. <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). A null
|
||||
/// or empty list means "do not constrain"; a non-empty list is translated to
|
||||
/// SQL <c>SourceNode IN (…)</c>. Rows with NULL <c>SourceNode</c> are excluded
|
||||
/// when the filter is set (the same SourceSiteIds contract).
|
||||
/// </param>
|
||||
public sealed record AuditLogQueryFilter(
|
||||
IReadOnlyList<AuditChannel>? Channels = null,
|
||||
IReadOnlyList<AuditKind>? Kinds = null,
|
||||
IReadOnlyList<AuditStatus>? Statuses = null,
|
||||
IReadOnlyList<string>? SourceSiteIds = null,
|
||||
string? Target = null,
|
||||
string? Actor = null,
|
||||
Guid? CorrelationId = null,
|
||||
Guid? ExecutionId = null,
|
||||
Guid? ParentExecutionId = null,
|
||||
DateTime? FromUtc = null,
|
||||
DateTime? ToUtc = null,
|
||||
IReadOnlyList<string>? SourceNodes = null);
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Shared lax parsers for the multi-value Audit Log query parameters
|
||||
/// (<c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c>). The Audit Log filter
|
||||
/// wire-contract is consumed by three surfaces that MUST stay in lockstep:
|
||||
/// <list type="bullet">
|
||||
/// <item>the ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c>
|
||||
/// endpoints,</item>
|
||||
/// <item>the CentralUI <c>/api/centralui/audit/export</c> endpoint, and</item>
|
||||
/// <item>the CentralUI <c>AuditLogPage</c> query-string drill-in parser.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Each caller extracts the raw repeated values for a single parameter from its
|
||||
/// own request type (ASP.NET <c>IQueryCollection</c>, a
|
||||
/// <c>Dictionary<string, StringValues></c> from <c>QueryHelpers.ParseQuery</c>,
|
||||
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
|
||||
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
|
||||
/// dependency and can live in <c>ZB.MOM.WW.ScadaBridge.Commons</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Lax-parse contract.</b> Every value of a repeated parameter is parsed
|
||||
/// independently; an unparseable or blank element is silently dropped (NO 400)
|
||||
/// rather than failing the whole set. An empty result collapses to <c>null</c> so
|
||||
/// the corresponding filter dimension stays unconstrained.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AuditQueryParamParsers
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
|
||||
/// dropping unparseable values silently. Returns <c>null</c> when
|
||||
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
|
||||
/// value — so the filter dimension stays unconstrained.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEnum">The enum type to parse each raw value into.</typeparam>
|
||||
/// <param name="rawValues">Raw query-parameter string values to parse; may be null.</param>
|
||||
/// <returns>A non-empty list of parsed values, or <c>null</c> if no values could be parsed.</returns>
|
||||
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
if (rawValues is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<TEnum>();
|
||||
foreach (var raw in rawValues)
|
||||
{
|
||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||
{
|
||||
parsed.Add(value);
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
|
||||
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
|
||||
/// blank.
|
||||
/// </summary>
|
||||
/// <param name="rawValues">Raw query-parameter string values to trim and filter; may be null.</param>
|
||||
/// <returns>A non-empty list of trimmed strings, or <c>null</c> if no non-blank values remain.</returns>
|
||||
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
|
||||
{
|
||||
if (rawValues is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = new List<string>();
|
||||
foreach (var raw in rawValues)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
parsed.Add(raw.Trim());
|
||||
}
|
||||
}
|
||||
return parsed.Count > 0 ? parsed : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// One execution within an execution chain returned by
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||
/// Each node summarises the <c>AuditLog</c> rows sharing a single
|
||||
/// <see cref="ExecutionId"/>; the Central UI renders the set as a tree by
|
||||
/// joining <see cref="ParentExecutionId"/> to a parent node's
|
||||
/// <see cref="ExecutionId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Stub nodes.</b> An execution that performed a trust-boundary action but
|
||||
/// crossed it without emitting any audit row — or whose own rows have been
|
||||
/// purged — still appears as a node when a child references it via
|
||||
/// <see cref="ParentExecutionId"/>. Such a stub node has <see cref="RowCount"/>
|
||||
/// = 0, empty <see cref="Channels"/>/<see cref="Statuses"/>, null
|
||||
/// <see cref="SourceSiteId"/>/<see cref="SourceInstanceId"/>, null timestamps,
|
||||
/// and a null <see cref="ParentExecutionId"/> (a purged/ghost parent leaves no
|
||||
/// row from which its own parent could be read — the upward walk ends there).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Channels"/> and <see cref="Statuses"/> are the distinct sets of
|
||||
/// the corresponding enum names present across the execution's rows, modelled
|
||||
/// as <see cref="IReadOnlyList{T}"/> of string to mirror how the repository's
|
||||
/// query filters already pass small bounded sets around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ExecutionId">The execution this node summarises.</param>
|
||||
/// <param name="ParentExecutionId">
|
||||
/// The <see cref="ExecutionId"/> of the spawning execution, or null for the
|
||||
/// root (and for stub nodes, whose own parent is unknowable).
|
||||
/// </param>
|
||||
/// <param name="RowCount">
|
||||
/// Number of <c>AuditLog</c> rows carrying this <see cref="ExecutionId"/>; 0 for
|
||||
/// a stub node.
|
||||
/// </param>
|
||||
/// <param name="Channels">
|
||||
/// Distinct <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditChannel"/> names
|
||||
/// present across this execution's rows; empty for a stub node.
|
||||
/// </param>
|
||||
/// <param name="Statuses">
|
||||
/// Distinct <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus"/> names
|
||||
/// present across this execution's rows; empty for a stub node.
|
||||
/// </param>
|
||||
/// <param name="SourceSiteId">
|
||||
/// Source site of the execution's rows when consistent; null for a stub node
|
||||
/// (or when the rows carry no site).
|
||||
/// </param>
|
||||
/// <param name="SourceInstanceId">
|
||||
/// Source instance of the execution's rows when consistent; null for a stub
|
||||
/// node (or when the rows carry no instance).
|
||||
/// </param>
|
||||
/// <param name="FirstOccurredAtUtc">
|
||||
/// Earliest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||
/// node.
|
||||
/// </param>
|
||||
/// <param name="LastOccurredAtUtc">
|
||||
/// Latest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||
/// node.
|
||||
/// </param>
|
||||
public sealed record ExecutionTreeNode(
|
||||
Guid ExecutionId,
|
||||
Guid? ParentExecutionId,
|
||||
int RowCount,
|
||||
IReadOnlyList<string> Channels,
|
||||
IReadOnlyList<string> Statuses,
|
||||
string? SourceSiteId,
|
||||
string? SourceInstanceId,
|
||||
DateTime? FirstOccurredAtUtc,
|
||||
DateTime? LastOccurredAtUtc);
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time operational metrics for the central <c>SiteCalls</c> table
|
||||
/// (Site Call Audit #22), surfaced on the health dashboard. The cached-call
|
||||
/// counterpart of <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications.NotificationKpiSnapshot"/>;
|
||||
/// mirrors its shape so the Central UI Site Calls KPI tiles can reuse the
|
||||
/// Notification Outbox tile layout.
|
||||
/// </summary>
|
||||
/// <param name="BufferedCount">
|
||||
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) — calls
|
||||
/// buffered at sites awaiting retry.
|
||||
/// </param>
|
||||
/// <param name="ParkedCount">Count of rows in the <c>Parked</c> status.</param>
|
||||
/// <param name="FailedLastInterval">
|
||||
/// Count of <c>Failed</c> rows whose <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||
/// is at or after the supplied "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of <c>Delivered</c> rows whose <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall.TerminalAtUtc"/>
|
||||
/// is at or after the supplied "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of the oldest non-terminal row (<c>now - min(CreatedAtUtc)</c>), or
|
||||
/// <c>null</c> when there are no non-terminal rows.
|
||||
/// </param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of non-terminal rows (<c>TerminalAtUtc IS NULL</c>) whose
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall.CreatedAtUtc"/> is older
|
||||
/// than the supplied stuck cutoff. Display-only — no escalation.
|
||||
/// </param>
|
||||
public sealed record SiteCallKpiSnapshot(
|
||||
int BufferedCount,
|
||||
int ParkedCount,
|
||||
int FailedLastInterval,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge,
|
||||
int StuckCount);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Keyset paging cursor for
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
|
||||
/// The repository orders by <c>(CreatedAtUtc DESC, TrackedOperationId DESC)</c> — newest
|
||||
/// calls first, with the strong-typed id breaking ties when two calls share an exact
|
||||
/// <c>CreatedAtUtc</c>. Callers pass the last row of the previous page back as
|
||||
/// <see cref="AfterCreatedAtUtc"/> + <see cref="AfterId"/> to fetch the next page.
|
||||
/// Both must be non-null together, or both null (first page).
|
||||
/// </summary>
|
||||
public sealed record SiteCallPaging(
|
||||
int PageSize,
|
||||
DateTime? AfterCreatedAtUtc = null,
|
||||
TrackedOperationId? AfterId = null);
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Filter predicate for <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteCallAuditRepository.QueryAsync"/>.
|
||||
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
|
||||
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
|
||||
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
|
||||
/// <c>>=</c> / <c><=</c> respectively. All filter fields are AND-combined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Channel / Status / SourceSite / Target are matched as exact strings — the
|
||||
/// underlying columns are bounded ASCII (varchar) and the Central UI Site Calls
|
||||
/// page exposes them as drop-down filters, not free-text search.
|
||||
/// </remarks>
|
||||
/// <param name="Channel">Restrict to a single channel (exact match).</param>
|
||||
/// <param name="SourceSite">Restrict to a single source site (exact match).</param>
|
||||
/// <param name="Status">Restrict to a single status (exact match).</param>
|
||||
/// <param name="Target">Restrict to a single target (exact match).</param>
|
||||
/// <param name="FromUtc">Inclusive lower bound on <c>CreatedAtUtc</c>.</param>
|
||||
/// <param name="ToUtc">Inclusive upper bound on <c>CreatedAtUtc</c>.</param>
|
||||
/// <param name="StuckCutoffUtc">
|
||||
/// When set, restrict to stuck rows: <c>TerminalAtUtc IS NULL AND CreatedAtUtc <
|
||||
/// StuckCutoffUtc</c>. Both columns are plain (no value converter) and compose
|
||||
/// directly with the keyset cursor. Mirrors
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications.NotificationOutboxFilter.StuckCutoff"/>;
|
||||
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
||||
/// pages with a non-null next cursor.
|
||||
/// </param>
|
||||
/// <param name="SourceNode">
|
||||
/// Restrict to cached calls originating at a specific cluster node (e.g.
|
||||
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
|
||||
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
|
||||
/// </param>
|
||||
public sealed record SiteCallQueryFilter(
|
||||
string? Channel = null,
|
||||
string? SourceSite = null,
|
||||
string? Status = null,
|
||||
string? Target = null,
|
||||
DateTime? FromUtc = null,
|
||||
DateTime? ToUtc = null,
|
||||
DateTime? StuckCutoffUtc = null,
|
||||
string? SourceNode = null);
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time <c>SiteCalls</c> metrics scoped to a single source site. The
|
||||
/// per-site counterpart of <see cref="SiteCallKpiSnapshot"/>; surfaced in the
|
||||
/// per-site breakdown table on the Site Calls KPIs page. Mirrors
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications.SiteNotificationKpiSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <param name="SourceSite">The site identifier these metrics are scoped to.</param>
|
||||
/// <param name="BufferedCount">Count of this site's non-terminal rows (<c>TerminalAtUtc IS NULL</c>).</param>
|
||||
/// <param name="ParkedCount">Count of this site's rows in the <c>Parked</c> status.</param>
|
||||
/// <param name="FailedLastInterval">
|
||||
/// Count of this site's <c>Failed</c> rows whose <c>TerminalAtUtc</c> is at or
|
||||
/// after the "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of this site's <c>Delivered</c> rows whose <c>TerminalAtUtc</c> is at
|
||||
/// or after the "since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
|
||||
/// </param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of this site's non-terminal rows whose <c>CreatedAtUtc</c> is older
|
||||
/// than the stuck cutoff.
|
||||
/// </param>
|
||||
public sealed record SiteCallSiteKpiSnapshot(
|
||||
string SourceSite,
|
||||
int BufferedCount,
|
||||
int ParkedCount,
|
||||
int FailedLastInterval,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge,
|
||||
int StuckCount);
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M7 Bundle E (T13) — point-in-time KPI snapshot for the central
|
||||
/// Health dashboard's "Audit" tile group. Aggregates volume + error counts over
|
||||
/// the trailing window from the central <c>AuditLog</c> table and combines them
|
||||
/// with the global pending backlog summed across every site's
|
||||
/// <see cref="SiteAuditBacklogSnapshot"/>.
|
||||
/// </summary>
|
||||
/// <param name="TotalEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows whose <c>OccurredAtUtc</c> falls inside the trailing
|
||||
/// 1-hour window. Drives the "Audit volume" tile and the denominator of
|
||||
/// "Audit error rate". A zero value renders as "0" rather than an em dash —
|
||||
/// "zero rows in the last hour" is a real, valid signal in a quiet system.
|
||||
/// </param>
|
||||
/// <param name="ErrorEventsLastHour">
|
||||
/// Total <c>AuditLog</c> rows in the same window whose <see cref="Enums.AuditStatus"/>
|
||||
/// is <c>Failed</c>, <c>Parked</c>, or <c>Discarded</c>. Drives the "Audit error
|
||||
/// rate" tile numerator; clicking the tile drills in to <c>/audit/log</c>
|
||||
/// pre-filtered on one of those statuses.
|
||||
/// </param>
|
||||
/// <param name="BacklogTotal">
|
||||
/// Sum of <c>SiteAuditBacklog.PendingCount</c> across every site's latest
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/>. Sites whose
|
||||
/// snapshot is <c>null</c> (no report yet, or reporter not running) contribute
|
||||
/// zero. A persistently non-zero value across multiple refresh ticks indicates
|
||||
/// the site→central drain isn't keeping up.
|
||||
/// </param>
|
||||
/// <param name="AsOfUtc">
|
||||
/// UTC timestamp at which the snapshot was computed. Used by the UI to label
|
||||
/// "as of HH:mm:ss" beneath the tile group and to detect stale data when a
|
||||
/// refresh tick fails.
|
||||
/// </param>
|
||||
public sealed record AuditLogKpiSnapshot(
|
||||
long TotalEventsLastHour,
|
||||
long ErrorEventsLastHour,
|
||||
long BacklogTotal,
|
||||
DateTime AsOfUtc);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public sealed class OpcUaDeadbandConfig
|
||||
{
|
||||
/// <summary>Gets or sets the OPC UA deadband type (Absolute or Percent).</summary>
|
||||
public OpcUaDeadbandType Type { get; set; } = OpcUaDeadbandType.Absolute;
|
||||
/// <summary>Gets or sets the deadband threshold value; meaning depends on <see cref="Type"/>.</summary>
|
||||
public double Value { get; set; } = 0.0;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public enum OpcUaDeadbandType
|
||||
{
|
||||
Absolute,
|
||||
Percent
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA endpoint configuration.
|
||||
/// </summary>
|
||||
public sealed class OpcUaEndpointConfig
|
||||
{
|
||||
// Connection
|
||||
/// <summary>
|
||||
/// The OPC UA endpoint URL.
|
||||
/// </summary>
|
||||
public string EndpointUrl { get; set; } = "";
|
||||
/// <summary>
|
||||
/// The security mode for the connection.
|
||||
/// </summary>
|
||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
||||
/// <summary>
|
||||
/// Whether to automatically accept untrusted certificates.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedCerts { get; set; } = true;
|
||||
|
||||
// Timing
|
||||
/// <summary>
|
||||
/// Session timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int SessionTimeoutMs { get; set; } = 60000;
|
||||
/// <summary>
|
||||
/// Operation timeout in milliseconds.
|
||||
/// </summary>
|
||||
public int OperationTimeoutMs { get; set; } = 15000;
|
||||
|
||||
// Subscription
|
||||
/// <summary>
|
||||
/// Publishing interval in milliseconds.
|
||||
/// </summary>
|
||||
public int PublishingIntervalMs { get; set; } = 1000;
|
||||
/// <summary>
|
||||
/// Sampling interval in milliseconds.
|
||||
/// </summary>
|
||||
public int SamplingIntervalMs { get; set; } = 1000;
|
||||
/// <summary>
|
||||
/// Queue size for the subscription.
|
||||
/// </summary>
|
||||
public int QueueSize { get; set; } = 10;
|
||||
/// <summary>
|
||||
/// Keep-alive count for the subscription.
|
||||
/// </summary>
|
||||
public int KeepAliveCount { get; set; } = 10;
|
||||
/// <summary>
|
||||
/// Lifetime count for the subscription.
|
||||
/// </summary>
|
||||
public int LifetimeCount { get; set; } = 30;
|
||||
/// <summary>
|
||||
/// Maximum notifications per publish.
|
||||
/// </summary>
|
||||
public int MaxNotificationsPerPublish { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// Whether to discard oldest notifications when queue is full.
|
||||
/// </summary>
|
||||
public bool DiscardOldest { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Subscription priority level.
|
||||
/// </summary>
|
||||
public byte SubscriptionPriority { get; set; } = 0;
|
||||
/// <summary>
|
||||
/// Display name for the subscription.
|
||||
/// </summary>
|
||||
public string SubscriptionDisplayName { get; set; } = "ScadaBridge";
|
||||
|
||||
// Read / filter
|
||||
/// <summary>
|
||||
/// Timestamps to return in read operations.
|
||||
/// </summary>
|
||||
public OpcUaTimestampsToReturn TimestampsToReturn { get; set; } = OpcUaTimestampsToReturn.Source;
|
||||
/// <summary>
|
||||
/// Deadband configuration for filtering notifications.
|
||||
/// </summary>
|
||||
public OpcUaDeadbandConfig? Deadband { get; set; }
|
||||
|
||||
// Authentication (optional; null = anonymous)
|
||||
/// <summary>
|
||||
/// User identity configuration for authentication.
|
||||
/// </summary>
|
||||
public OpcUaUserIdentityConfig? UserIdentity { get; set; }
|
||||
|
||||
// Heartbeat (optional)
|
||||
/// <summary>
|
||||
/// Heartbeat configuration for connection monitoring.
|
||||
/// </summary>
|
||||
public OpcUaHeartbeatConfig? Heartbeat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public sealed class OpcUaHeartbeatConfig
|
||||
{
|
||||
/// <summary>OPC UA node path of the heartbeat tag to monitor for activity.</summary>
|
||||
public string TagPath { get; set; } = "";
|
||||
/// <summary>Maximum number of seconds without a value update before the connection is considered unhealthy.</summary>
|
||||
public int MaxSilenceSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
None,
|
||||
Sign,
|
||||
SignAndEncrypt
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public enum OpcUaTimestampsToReturn
|
||||
{
|
||||
Source,
|
||||
Server,
|
||||
Both
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA user identity configuration for a data connection endpoint.
|
||||
/// </summary>
|
||||
public sealed class OpcUaUserIdentityConfig
|
||||
{
|
||||
/// <summary>The OPC UA user token type (Anonymous, UserName, or Certificate).</summary>
|
||||
public OpcUaUserTokenType TokenType { get; set; } = OpcUaUserTokenType.Anonymous;
|
||||
/// <summary>Username for UserName token type authentication.</summary>
|
||||
public string Username { get; set; } = "";
|
||||
/// <summary>Password for UserName token type authentication.</summary>
|
||||
public string Password { get; set; } = "";
|
||||
/// <summary>File path to the X.509 certificate for Certificate token type authentication.</summary>
|
||||
public string CertificatePath { get; set; } = "";
|
||||
/// <summary>Password to unlock the certificate private key.</summary>
|
||||
public string CertificatePassword { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
public enum OpcUaUserTokenType
|
||||
{
|
||||
Anonymous,
|
||||
UsernamePassword,
|
||||
X509Certificate
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
|
||||
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
|
||||
/// Array indexing accepts any integral index type (int, long, short, byte, ...), so an
|
||||
/// index derived from another wrapped JSON number — which Wrap surfaces as long — works.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The element passed to the constructor is <see cref="JsonElement.Clone()">cloned</see>
|
||||
/// so the wrapper owns a self-contained copy. This decouples its lifetime from the
|
||||
/// <see cref="JsonDocument"/> the element originated from: a wrapper built from an
|
||||
/// element inside a <c>using</c> block remains valid for deferred (e.g. script-time)
|
||||
/// access after that document has been disposed.
|
||||
/// </remarks>
|
||||
public class DynamicJsonElement : DynamicObject
|
||||
{
|
||||
private readonly JsonElement _element;
|
||||
|
||||
/// <summary>Initializes a new <see cref="DynamicJsonElement"/> wrapping a clone of the given <see cref="JsonElement"/>.</summary>
|
||||
/// <param name="element">The JSON element to wrap; it is cloned to decouple lifetime from the source document.</param>
|
||||
public DynamicJsonElement(JsonElement element)
|
||||
{
|
||||
// Clone detaches the element from its owning JsonDocument so accessing it
|
||||
// later cannot throw ObjectDisposedException once that document is disposed.
|
||||
_element = element.Clone();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryGetMember(GetMemberBinder binder, out object? result)
|
||||
{
|
||||
if (_element.ValueKind == JsonValueKind.Object &&
|
||||
_element.TryGetProperty(binder.Name, out var prop))
|
||||
{
|
||||
result = Wrap(prop);
|
||||
return true;
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object? result)
|
||||
{
|
||||
// Accept any integral index, not just int. DynamicJsonElement surfaces JSON
|
||||
// numbers as long (see Wrap), so an index computed from another wrapped value
|
||||
// (e.g. obj.items[obj.count - 1]) arrives as a long; byte/short widen too.
|
||||
if (_element.ValueKind == JsonValueKind.Array &&
|
||||
indexes.Length == 1 && TryGetIntegralIndex(indexes[0], out var index))
|
||||
{
|
||||
var arrayLength = _element.GetArrayLength();
|
||||
if (index >= 0 && index < arrayLength)
|
||||
{
|
||||
result = Wrap(_element[(int)index]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetIntegralIndex(object? value, out long index)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case int i: index = i; return true;
|
||||
case long l: index = l; return true;
|
||||
case short s: index = s; return true;
|
||||
case byte b: index = b; return true;
|
||||
case sbyte sb: index = sb; return true;
|
||||
case ushort us: index = us; return true;
|
||||
case uint ui: index = ui; return true;
|
||||
case ulong ul when ul <= long.MaxValue: index = (long)ul; return true;
|
||||
// Whole-valued floating-point indices are accepted too: arithmetic on
|
||||
// wrapped JSON numbers can yield a double/decimal even for an integer result.
|
||||
case double d when d >= long.MinValue && d <= long.MaxValue && d == Math.Floor(d):
|
||||
index = (long)d; return true;
|
||||
case decimal m when m == Math.Floor(m):
|
||||
index = (long)m; return true;
|
||||
default: index = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryConvert(ConvertBinder binder, out object? result)
|
||||
{
|
||||
// Conversion to object (or dynamic): never null out a present value. Return the
|
||||
// unwrapped value for scalars, this wrapper for objects/arrays, and null only
|
||||
// when the element is genuinely JSON null.
|
||||
if (binder.Type == typeof(object))
|
||||
{
|
||||
result = _element.ValueKind == JsonValueKind.Null ? null : Wrap(_element);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = ConvertTo(binder.Type);
|
||||
// A non-object target with a null result means ConvertTo could not handle the
|
||||
// element/type pair — report failure so the binder surfaces a binding error.
|
||||
return result != null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return _element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => _element.GetString() ?? "",
|
||||
JsonValueKind.Number => _element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "",
|
||||
_ => _element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private object? ConvertTo(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return ToString();
|
||||
if (type == typeof(int) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt32();
|
||||
if (type == typeof(long) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt64();
|
||||
if (type == typeof(double) && _element.ValueKind == JsonValueKind.Number) return _element.GetDouble();
|
||||
if (type == typeof(decimal) && _element.ValueKind == JsonValueKind.Number) return _element.GetDecimal();
|
||||
if (type == typeof(bool) && (_element.ValueKind == JsonValueKind.True || _element.ValueKind == JsonValueKind.False))
|
||||
return _element.GetBoolean();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? Wrap(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => new DynamicJsonElement(element),
|
||||
JsonValueKind.Array => new DynamicJsonElement(element),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => WrapNumber(element),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static object WrapNumber(JsonElement element)
|
||||
{
|
||||
// An integral JSON number must box as long, not double. A ternary
|
||||
// (TryGetInt64 ? long : double) would unify both branches to double and
|
||||
// silently widen the long, so an integral index read back out of the wrapper
|
||||
// (e.g. obj.items[obj.count - 1]) would arrive as a double. Box each case
|
||||
// with its own typed return so the runtime type is preserved.
|
||||
if (element.TryGetInt64(out var l))
|
||||
return l;
|
||||
return element.GetDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Severity level for an active alarm. Binary alarm types (ValueMatch,
|
||||
/// RangeViolation, RateOfChange) always emit <see cref="None"/>. The HiLo
|
||||
/// trigger type emits one of the directional levels based on which setpoint
|
||||
/// the monitored attribute has crossed.
|
||||
///
|
||||
/// Conventional ordering (lowest setpoint to highest):
|
||||
/// LowLow < Low < normal-band < High < HighHigh
|
||||
/// </summary>
|
||||
public enum AlarmLevel
|
||||
{
|
||||
None,
|
||||
Low,
|
||||
LowLow,
|
||||
High,
|
||||
HighHigh
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum AlarmState
|
||||
{
|
||||
Active,
|
||||
Normal
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum AlarmTriggerType
|
||||
{
|
||||
ValueMatch,
|
||||
RangeViolation,
|
||||
RateOfChange,
|
||||
/// <summary>
|
||||
/// Multi-setpoint level alarm: monitors a single numeric attribute against
|
||||
/// up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Each setpoint
|
||||
/// may carry its own priority; transitions between levels emit a fresh
|
||||
/// AlarmStateChanged with the corresponding <see cref="AlarmLevel"/>.
|
||||
/// </summary>
|
||||
HiLo,
|
||||
|
||||
/// <summary>
|
||||
/// Read-only boolean C# expression evaluated on attribute updates. The
|
||||
/// trigger fires when the expression evaluates to <c>true</c>.
|
||||
/// </summary>
|
||||
Expression
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Audit Log (#23) channel — the trust boundary the audited action crosses.
|
||||
/// One of: outbound API call, outbound DB write, notification send/deliver, or inbound API request.
|
||||
/// </summary>
|
||||
public enum AuditChannel
|
||||
{
|
||||
ApiOutbound,
|
||||
DbOutbound,
|
||||
Notification,
|
||||
ApiInbound
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Site-local Audit Log (#23) forwarding state, tracked only in the site SQLite hot-path.
|
||||
/// Central rows leave this null. <c>Pending</c> = not yet sent; <c>Forwarded</c> = telemetry sent
|
||||
/// and acked; <c>Reconciled</c> = confirmed present centrally via the periodic pull fallback.
|
||||
/// The site retention purge MUST NOT drop a row whose state is still <c>Pending</c>.
|
||||
/// </summary>
|
||||
public enum AuditForwardState
|
||||
{
|
||||
Pending,
|
||||
Forwarded,
|
||||
Reconciled
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Specific Audit Log (#23) event kind within a channel — what action produced the row.
|
||||
/// Cached variants emit multiple rows per operation (submit → forward → attempt → resolve).
|
||||
/// See alog.md §4 for the full taxonomy.
|
||||
/// </summary>
|
||||
public enum AuditKind
|
||||
{
|
||||
ApiCall,
|
||||
ApiCallCached,
|
||||
DbWrite,
|
||||
DbWriteCached,
|
||||
NotifySend,
|
||||
NotifyDeliver,
|
||||
InboundRequest,
|
||||
InboundAuthFailure,
|
||||
CachedSubmit,
|
||||
CachedResolve
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of an Audit Log (#23) event row.
|
||||
/// Cached operations produce multiple rows tracking <c>Submitted → Forwarded → Attempted → Delivered/Parked/Discarded</c>.
|
||||
/// <c>Skipped</c> is used when an action was short-circuited (e.g. dry-run) but should still be audited.
|
||||
/// </summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
Submitted,
|
||||
Forwarded,
|
||||
Attempted,
|
||||
Delivered,
|
||||
Failed,
|
||||
Parked,
|
||||
Discarded,
|
||||
Skipped
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum ConnectionHealth
|
||||
{
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum DataType
|
||||
{
|
||||
Boolean,
|
||||
Int32,
|
||||
Float,
|
||||
Double,
|
||||
String,
|
||||
DateTime,
|
||||
Binary
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum DeploymentStatus
|
||||
{
|
||||
Pending,
|
||||
InProgress,
|
||||
Success,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
public enum InstanceState
|
||||
{
|
||||
NotDeployed,
|
||||
Enabled,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status of a notification in the central outbox. The site-local
|
||||
/// <c>Forwarding</c> concept is intentionally not part of the central status set.
|
||||
/// </summary>
|
||||
public enum NotificationStatus
|
||||
{
|
||||
Pending,
|
||||
Retrying,
|
||||
Delivered,
|
||||
Parked,
|
||||
Discarded
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery channel for a notification. Currently only email is supported.
|
||||
/// </summary>
|
||||
public enum NotificationType
|
||||
{
|
||||
Email
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9: Categories for store-and-forward messages.
|
||||
/// </summary>
|
||||
public enum StoreAndForwardCategory
|
||||
{
|
||||
ExternalSystem,
|
||||
Notification,
|
||||
CachedDbWrite
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9: Status of a store-and-forward message.
|
||||
/// </summary>
|
||||
public enum StoreAndForwardMessageStatus
|
||||
{
|
||||
Pending,
|
||||
InFlight,
|
||||
Parked,
|
||||
Delivered
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the difference between two FlattenedConfigurations (typically deployed vs current).
|
||||
/// Used for incremental deployment decisions and change review.
|
||||
/// </summary>
|
||||
public sealed record ConfigurationDiff
|
||||
{
|
||||
/// <summary>Unique name of the instance this diff applies to.</summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>Revision hash of the previously deployed configuration, or null if not previously deployed.</summary>
|
||||
public string? OldRevisionHash { get; init; }
|
||||
/// <summary>Revision hash of the new configuration being compared.</summary>
|
||||
public string? NewRevisionHash { get; init; }
|
||||
/// <summary>True when any attribute, alarm, or script changes are present.</summary>
|
||||
public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0;
|
||||
|
||||
/// <summary>Diff entries for resolved attributes.</summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedAttribute>> AttributeChanges { get; init; } = [];
|
||||
/// <summary>Diff entries for resolved alarms.</summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedAlarm>> AlarmChanges { get; init; } = [];
|
||||
/// <summary>Diff entries for resolved scripts.</summary>
|
||||
public IReadOnlyList<DiffEntry<ResolvedScript>> ScriptChanges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single diff entry showing what changed for a named entity.
|
||||
/// </summary>
|
||||
public sealed record DiffEntry<T>
|
||||
{
|
||||
/// <summary>The canonical name of the changed entity.</summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>The type of change: Added, Removed, or Changed.</summary>
|
||||
public DiffChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The previous value (null for Added entries).
|
||||
/// </summary>
|
||||
public T? OldValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new value (null for Removed entries).
|
||||
/// </summary>
|
||||
public T? NewValue { get; init; }
|
||||
}
|
||||
|
||||
public enum DiffChangeType
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Changed
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// The complete deployment package for an instance, transmitted to site clusters and stored in SQLite.
|
||||
/// Contains the flattened configuration, revision hash, and deployment metadata.
|
||||
///
|
||||
/// JSON serialization format:
|
||||
/// {
|
||||
/// "instanceUniqueName": "PumpStation1",
|
||||
/// "deploymentId": "dep-abc123",
|
||||
/// "revisionHash": "sha256:...",
|
||||
/// "deployedBy": "admin@company.com",
|
||||
/// "deployedAtUtc": "2026-03-16T12:00:00Z",
|
||||
/// "configuration": { /* FlattenedConfiguration */ },
|
||||
/// "diff": { /* ConfigurationDiff, null for first deployment */ },
|
||||
/// "previousRevisionHash": null
|
||||
/// }
|
||||
/// </summary>
|
||||
public sealed record DeploymentPackage
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique name of the instance being deployed.
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Unique deployment ID for idempotency (deployment ID + revision hash).
|
||||
/// </summary>
|
||||
public string DeploymentId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the flattened configuration for staleness detection.
|
||||
/// </summary>
|
||||
public string RevisionHash { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The user who initiated the deployment.
|
||||
/// </summary>
|
||||
public string DeployedBy { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the deployment was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset DeployedAtUtc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully resolved configuration to deploy.
|
||||
/// </summary>
|
||||
public FlattenedConfiguration Configuration { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Diff against the previously deployed configuration. Null for first-time deployments.
|
||||
/// </summary>
|
||||
public ConfigurationDiff? Diff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The revision hash of the previously deployed configuration, if any.
|
||||
/// Used for optimistic concurrency on deployment status records.
|
||||
/// </summary>
|
||||
public string? PreviousRevisionHash { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// The fully resolved configuration for a single instance, produced by the flattening algorithm.
|
||||
/// All inheritance, composition, overrides, and connection bindings have been applied.
|
||||
/// This is the canonical representation sent to sites for deployment.
|
||||
/// </summary>
|
||||
public sealed record FlattenedConfiguration
|
||||
{
|
||||
/// <summary>Gets the instance unique name.</summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>Gets the template ID.</summary>
|
||||
public int TemplateId { get; init; }
|
||||
/// <summary>Gets the site ID.</summary>
|
||||
public int SiteId { get; init; }
|
||||
/// <summary>Gets the area ID, if any.</summary>
|
||||
public int? AreaId { get; init; }
|
||||
/// <summary>Gets the resolved attributes.</summary>
|
||||
public IReadOnlyList<ResolvedAttribute> Attributes { get; init; } = [];
|
||||
/// <summary>Gets the resolved alarms.</summary>
|
||||
public IReadOnlyList<ResolvedAlarm> Alarms { get; init; } = [];
|
||||
/// <summary>Gets the resolved scripts.</summary>
|
||||
public IReadOnlyList<ResolvedScript> Scripts { get; init; } = [];
|
||||
/// <summary>Gets the UTC timestamp when this configuration was generated.</summary>
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Connection configurations keyed by connection name, each containing the
|
||||
/// protocol-specific settings (e.g. OPC UA endpoint, publish interval).
|
||||
/// Populated during flattening from the instance's connection bindings.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ConnectionConfig>? Connections { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection configuration included in the flattened deployment package.
|
||||
/// </summary>
|
||||
public sealed record ConnectionConfig
|
||||
{
|
||||
/// <summary>Gets the protocol name (e.g., "OpcUa").</summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
/// <summary>Gets the primary configuration as JSON.</summary>
|
||||
public string? ConfigurationJson { get; init; }
|
||||
/// <summary>Gets the backup configuration as JSON.</summary>
|
||||
public string? BackupConfigurationJson { get; init; }
|
||||
/// <summary>Gets the number of failover retries.</summary>
|
||||
public int FailoverRetryCount { get; init; } = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved attribute with its canonical name, value, data type, and optional data source binding.
|
||||
/// </summary>
|
||||
public sealed record ResolvedAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Path-qualified canonical name. For composed modules: "[ModuleInstanceName].[MemberName]".
|
||||
/// For direct attributes: just the attribute name.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the attribute value.</summary>
|
||||
public string? Value { get; init; }
|
||||
/// <summary>Gets the data type name.</summary>
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
/// <summary>Gets whether the attribute is locked.</summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>Gets the attribute description.</summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the relative tag path within the connection.
|
||||
/// </summary>
|
||||
public string? DataSourceReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the resolved connection ID from the instance binding.
|
||||
/// </summary>
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the resolved connection name.
|
||||
/// </summary>
|
||||
public string? BoundDataConnectionName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If data-sourced: the connection protocol (e.g. "OpcUa").
|
||||
/// </summary>
|
||||
public string? BoundDataConnectionProtocol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of this attribute value: "Template", "Inherited", "Composed", "Override".
|
||||
/// </summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved alarm with trigger definition containing resolved attribute references.
|
||||
/// </summary>
|
||||
public sealed record ResolvedAlarm
|
||||
{
|
||||
/// <summary>Gets the path-qualified canonical name.</summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>Gets the alarm description.</summary>
|
||||
public string? Description { get; init; }
|
||||
/// <summary>Gets the priority level.</summary>
|
||||
public int PriorityLevel { get; init; }
|
||||
/// <summary>Gets whether the alarm is locked.</summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>Gets the trigger type.</summary>
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// JSON trigger configuration with resolved attribute references (canonical names).
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical name of the on-trigger script, if any.
|
||||
/// </summary>
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
|
||||
/// <summary>Gets the source of this alarm value: "Template", "Inherited", "Composed", or "Override".</summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fully resolved script with code, trigger config, parameters, and return definition.
|
||||
/// </summary>
|
||||
public sealed record ResolvedScript
|
||||
{
|
||||
/// <summary>Gets the path-qualified canonical name.</summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>Gets the script code.</summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>Gets whether the script is locked.</summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>Gets the trigger type.</summary>
|
||||
public string? TriggerType { get; init; }
|
||||
/// <summary>Gets the trigger configuration.</summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized parameter definitions.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized return type definition.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
|
||||
/// <summary>Gets the minimum time between script executions.</summary>
|
||||
public TimeSpan? MinTimeBetweenRuns { get; init; }
|
||||
/// <summary>Gets the source of this script.</summary>
|
||||
public string Source { get; init; } = "Template";
|
||||
|
||||
/// <summary>
|
||||
/// Where the script sits in the composition tree. Seeded into ScriptGlobals
|
||||
/// so the script's <c>Attributes</c> / <c>Children</c> / <c>Parent</c>
|
||||
/// accessors resolve canonical names with the right path-prefix.
|
||||
/// </summary>
|
||||
public ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptScope Scope { get; init; }
|
||||
= ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptScope.Root;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Result of pre-deployment or on-demand validation with categorized errors and warnings.
|
||||
/// </summary>
|
||||
public sealed record ValidationResult
|
||||
{
|
||||
/// <summary>True when there are no validation errors.</summary>
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
/// <summary>Validation errors that block the operation.</summary>
|
||||
public IReadOnlyList<ValidationEntry> Errors { get; init; } = [];
|
||||
/// <summary>Non-blocking validation warnings.</summary>
|
||||
public IReadOnlyList<ValidationEntry> Warnings { get; init; } = [];
|
||||
|
||||
/// <summary>Returns a result with no errors or warnings.</summary>
|
||||
public static ValidationResult Success() => new();
|
||||
|
||||
/// <summary>Returns a result containing the given errors.</summary>
|
||||
/// <param name="errors">The validation errors to include.</param>
|
||||
public static ValidationResult FromErrors(params ValidationEntry[] errors) =>
|
||||
new() { Errors = errors };
|
||||
|
||||
/// <summary>Merges multiple validation results into a single combined result.</summary>
|
||||
/// <param name="results">The results to merge.</param>
|
||||
public static ValidationResult Merge(params ValidationResult[] results)
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
var warnings = new List<ValidationEntry>();
|
||||
foreach (var r in results)
|
||||
{
|
||||
errors.AddRange(r.Errors);
|
||||
warnings.AddRange(r.Warnings);
|
||||
}
|
||||
return new ValidationResult { Errors = errors, Warnings = warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single validation error or warning with category and context.
|
||||
/// </summary>
|
||||
public sealed record ValidationEntry
|
||||
{
|
||||
/// <summary>The category classifying the kind of validation failure.</summary>
|
||||
public ValidationCategory Category { get; init; }
|
||||
/// <summary>Human-readable description of the validation issue.</summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The canonical name of the entity that caused the validation issue, if applicable.
|
||||
/// </summary>
|
||||
public string? EntityName { get; init; }
|
||||
|
||||
/// <summary>Creates an error entry with the given category, message, and optional entity name.</summary>
|
||||
/// <param name="category">The validation category.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="entityName">The canonical name of the entity that caused the error, if any.</param>
|
||||
public static ValidationEntry Error(ValidationCategory category, string message, string? entityName = null) =>
|
||||
new() { Category = category, Message = message, EntityName = entityName };
|
||||
|
||||
/// <summary>Creates a warning entry with the given category, message, and optional entity name.</summary>
|
||||
/// <param name="category">The validation category.</param>
|
||||
/// <param name="message">The warning message.</param>
|
||||
/// <param name="entityName">The canonical name of the entity that triggered the warning, if any.</param>
|
||||
public static ValidationEntry Warning(ValidationCategory category, string message, string? entityName = null) =>
|
||||
new() { Category = category, Message = message, EntityName = entityName };
|
||||
}
|
||||
|
||||
public enum ValidationCategory
|
||||
{
|
||||
FlatteningFailure,
|
||||
NamingCollision,
|
||||
ScriptCompilation,
|
||||
AlarmTriggerReference,
|
||||
ScriptTriggerReference,
|
||||
ConnectionBinding,
|
||||
CallTargetNotFound,
|
||||
ParameterMismatch,
|
||||
ReturnTypeMismatch,
|
||||
TriggerOperandType,
|
||||
OnTriggerScriptNotFound,
|
||||
CrossCallViolation,
|
||||
MissingMetadata,
|
||||
ConnectionConfig
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic, keyed hash of an inbound-API key value
|
||||
/// (ConfigurationDatabase-012). API keys are persisted as this hash, never as
|
||||
/// plaintext, so a configuration-database dump does not yield usable credentials.
|
||||
/// The hash is deterministic so authentication can still resolve a key by value.
|
||||
/// </summary>
|
||||
public interface IApiKeyHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the keyed hash of <paramref name="apiKey"/> as a Base64 string.
|
||||
/// The same input always produces the same output (deterministic), which keeps
|
||||
/// the by-value lookup working.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The raw API key to hash.</param>
|
||||
/// <returns>A Base64-encoded HMAC-SHA256 hash of the key.</returns>
|
||||
string Hash(string apiKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HMAC-SHA256 implementation of <see cref="IApiKeyHasher"/>. The HMAC key is a
|
||||
/// server-side <em>pepper</em> bound from configuration. A per-row random salt is
|
||||
/// intentionally NOT used: an API key is already a high-entropy random token, and a
|
||||
/// random salt would break the deterministic by-value lookup the authentication
|
||||
/// path relies on. The pepper instead binds every hash to this deployment, so a
|
||||
/// stolen database is useless without it.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyHasher : IApiKeyHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum accepted pepper length. A pepper shorter than this is treated as a
|
||||
/// deployment misconfiguration and rejected — see <see cref="ApiKeyHasher(string)"/>.
|
||||
/// </summary>
|
||||
public const int MinimumPepperLength = 16;
|
||||
|
||||
private readonly byte[] _pepper;
|
||||
|
||||
/// <summary>
|
||||
/// An unpeppered hasher (HMAC-SHA256 keyed with a fixed, empty-equivalent value).
|
||||
/// It is still a one-way hash, but carries no deployment-specific binding. It
|
||||
/// exists for tests and non-production wiring; production must construct an
|
||||
/// <see cref="ApiKeyHasher"/> with a real pepper.
|
||||
/// </summary>
|
||||
public static ApiKeyHasher Default { get; } = new ApiKeyHasher();
|
||||
|
||||
private ApiKeyHasher()
|
||||
{
|
||||
// Fixed, deployment-independent key for the unpeppered default.
|
||||
_pepper = Encoding.UTF8.GetBytes("ZB.MOM.WW.ScadaBridge.InboundApi.DefaultApiKeyHasher");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a hasher keyed with the given server-side pepper.
|
||||
/// </summary>
|
||||
/// <param name="pepper">Server-side HMAC key; must be at least <see cref="MinimumPepperLength"/> characters.</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown if <paramref name="pepper"/> is null, blank, or shorter than
|
||||
/// <see cref="MinimumPepperLength"/> — a missing or weak pepper is a deployment
|
||||
/// misconfiguration and must fail loudly rather than degrade silently.
|
||||
/// </exception>
|
||||
public ApiKeyHasher(string pepper)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The API-key HMAC pepper must be configured. Set a strong, random value " +
|
||||
"in configuration (ScadaBridge:InboundApi:ApiKeyPepper).",
|
||||
nameof(pepper));
|
||||
}
|
||||
|
||||
if (pepper.Trim().Length < MinimumPepperLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The API-key HMAC pepper is too weak: it must be at least {MinimumPepperLength} " +
|
||||
"characters. Use a strong, random value.",
|
||||
nameof(pepper));
|
||||
}
|
||||
|
||||
_pepper = Encoding.UTF8.GetBytes(pepper);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Hash(string apiKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(apiKey);
|
||||
|
||||
using var hmac = new HMACSHA256(_pepper);
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a single parameter in an inbound API method's parameter definitions.
|
||||
/// This is the deserialized, persistence-ignorant form of the JSON stored in
|
||||
/// <c>ApiMethod.ParameterDefinitions</c> and describes the public API contract,
|
||||
/// so it is shared by every component that reads or produces method parameter
|
||||
/// definitions (Inbound API, Central UI method editor, CLI, Management Service).
|
||||
/// </summary>
|
||||
public class ParameterDefinition
|
||||
{
|
||||
/// <summary>Gets or sets the parameter name as it must appear in the JSON request body.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the expected type (e.g. "String", "Integer", "Float", "Boolean", "Object", "List").</summary>
|
||||
public string Type { get; set; } = "String";
|
||||
/// <summary>Gets or sets whether this parameter must be present in the request body.</summary>
|
||||
public bool Required { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time operational metrics for the central notification outbox,
|
||||
/// surfaced on the health dashboard.
|
||||
/// </summary>
|
||||
/// <param name="QueueDepth">Count of non-terminal rows (Pending + Retrying).</param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of non-terminal rows (Pending/Retrying) whose <c>CreatedAt</c> is older
|
||||
/// than the supplied stuck cutoff.
|
||||
/// </param>
|
||||
/// <param name="ParkedCount">Count of rows in the Parked status.</param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of Delivered rows whose <c>DeliveredAt</c> is at or after the supplied
|
||||
/// "delivered since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of the oldest non-terminal row (<c>now - min(CreatedAt)</c>), or <c>null</c>
|
||||
/// when there are no non-terminal rows.
|
||||
/// </param>
|
||||
public record NotificationKpiSnapshot(
|
||||
int QueueDepth,
|
||||
int StuckCount,
|
||||
int ParkedCount,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge);
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Query filter for the central notification outbox. All members are optional;
|
||||
/// an unset member means "no constraint on that dimension".
|
||||
/// </summary>
|
||||
/// <param name="Status">Restrict to a single lifecycle status.</param>
|
||||
/// <param name="Type">Restrict to a single delivery channel.</param>
|
||||
/// <param name="SourceSiteId">Restrict to notifications originating at a given site.</param>
|
||||
/// <param name="ListName">Restrict to a single notification list.</param>
|
||||
/// <param name="SubjectKeyword">Substring matched against <c>Subject</c>.</param>
|
||||
/// <param name="StuckOnly">
|
||||
/// When <c>true</c>, restrict to non-terminal rows (Pending/Retrying) whose
|
||||
/// <c>CreatedAt</c> is older than <see cref="StuckCutoff"/>.
|
||||
/// </param>
|
||||
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
|
||||
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
|
||||
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
|
||||
/// <param name="SourceNode">
|
||||
/// Restrict to notifications originating at a specific cluster node (e.g.
|
||||
/// <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c>
|
||||
/// means "do not constrain".
|
||||
/// </param>
|
||||
public record NotificationOutboxFilter(
|
||||
NotificationStatus? Status = null,
|
||||
NotificationType? Type = null,
|
||||
string? SourceSiteId = null,
|
||||
string? ListName = null,
|
||||
string? SubjectKeyword = null,
|
||||
bool StuckOnly = false,
|
||||
DateTimeOffset? StuckCutoff = null,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null,
|
||||
string? SourceNode = null);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time notification-outbox metrics scoped to a single source site.
|
||||
/// The per-site counterpart of <see cref="NotificationKpiSnapshot"/>; surfaced
|
||||
/// in the per-site breakdown table on the Notification KPIs page.
|
||||
/// </summary>
|
||||
/// <param name="SourceSiteId">The site identifier these metrics are scoped to.</param>
|
||||
/// <param name="QueueDepth">Count of this site's non-terminal rows (Pending + Retrying).</param>
|
||||
/// <param name="StuckCount">
|
||||
/// Count of this site's non-terminal rows whose <c>CreatedAt</c> is older than the stuck cutoff.
|
||||
/// </param>
|
||||
/// <param name="ParkedCount">Count of this site's rows in the Parked status.</param>
|
||||
/// <param name="DeliveredLastInterval">
|
||||
/// Count of this site's Delivered rows whose <c>DeliveredAt</c> is at or after the
|
||||
/// "delivered since" timestamp.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingAge">
|
||||
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
|
||||
/// </param>
|
||||
public record SiteNotificationKpiSnapshot(
|
||||
string SourceSiteId,
|
||||
int QueueDepth,
|
||||
int StuckCount,
|
||||
int ParkedCount,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge);
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
public sealed class Result<T>
|
||||
{
|
||||
private readonly T? _value;
|
||||
private readonly string? _error;
|
||||
|
||||
private Result(T value)
|
||||
{
|
||||
_value = value;
|
||||
_error = null;
|
||||
IsSuccess = true;
|
||||
}
|
||||
|
||||
private Result(string error)
|
||||
{
|
||||
// A failed Result must always carry a usable message — Result is the
|
||||
// system-wide error-handling type, and consumers log/display Error directly.
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(error);
|
||||
_value = default;
|
||||
_error = error;
|
||||
IsSuccess = false;
|
||||
}
|
||||
|
||||
/// <summary>True when the result represents a successful outcome.</summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>True when the result represents a failure outcome.</summary>
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
/// <summary>The success value; throws <see cref="InvalidOperationException"/> when <see cref="IsFailure"/> is true.</summary>
|
||||
public T Value => IsSuccess
|
||||
? _value!
|
||||
: throw new InvalidOperationException("Cannot access Value on a failed Result. Error: " + _error);
|
||||
|
||||
/// <summary>The error message; throws <see cref="InvalidOperationException"/> when <see cref="IsSuccess"/> is true.</summary>
|
||||
public string Error => IsFailure
|
||||
? _error!
|
||||
: throw new InvalidOperationException("Cannot access Error on a successful Result.");
|
||||
|
||||
/// <summary>Creates a successful result carrying the given value.</summary>
|
||||
/// <param name="value">The success value.</param>
|
||||
/// <returns>A successful <see cref="Result{T}"/>.</returns>
|
||||
public static Result<T> Success(T value) => new(value);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result carrying the given error message.
|
||||
/// </summary>
|
||||
/// <param name="error">Non-blank error message describing the failure.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="error"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="error"/> is empty or whitespace.</exception>
|
||||
public static Result<T> Failure(string error) => new(error);
|
||||
|
||||
/// <summary>Pattern-matches the result, invoking either the success or failure delegate.</summary>
|
||||
/// <typeparam name="TResult">The return type of both delegates.</typeparam>
|
||||
/// <param name="onSuccess">Delegate invoked with the value when the result is successful.</param>
|
||||
/// <param name="onFailure">Delegate invoked with the error message when the result is a failure.</param>
|
||||
/// <returns>The value returned by the invoked delegate.</returns>
|
||||
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure) =>
|
||||
IsSuccess ? onSuccess(_value!) : onFailure(_error!);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
public record RetryPolicy(int MaxRetries, TimeSpan Delay);
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the loosely-typed <c>parameters</c> argument of a script call
|
||||
/// (<c>Scripts.CallShared</c>, <c>Instance.CallScript</c>,
|
||||
/// <c>Children["X"].CallScript</c>, <c>Parent.CallScript</c>,
|
||||
/// <c>Route.To().Call</c>) into the dictionary the runtime carries.
|
||||
///
|
||||
/// Accepts: <c>null</c>; an existing dictionary; or any object whose public
|
||||
/// properties become the parameter entries — so callers can pass an anonymous
|
||||
/// object, <c>new { name = "Bob", count = 3 }</c>, instead of building a
|
||||
/// <c>Dictionary<string, object?></c> by hand.
|
||||
/// </summary>
|
||||
public static class ScriptArgs
|
||||
{
|
||||
/// <summary>Normalizes a loosely-typed parameters argument into a read-only string-keyed dictionary, or null if no parameters were supplied.</summary>
|
||||
/// <param name="parameters">Null, an existing dictionary, or an anonymous object whose properties become parameter entries.</param>
|
||||
/// <returns>A normalized read-only dictionary, or null when <paramref name="parameters"/> is null.</returns>
|
||||
public static IReadOnlyDictionary<string, object?>? Normalize(object? parameters)
|
||||
{
|
||||
switch (parameters)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case IReadOnlyDictionary<string, object?> roDict:
|
||||
return roDict;
|
||||
case IDictionary<string, object?> dict:
|
||||
return new Dictionary<string, object?>(dict);
|
||||
case IDictionary raw:
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (DictionaryEntry entry in raw)
|
||||
result[entry.Key?.ToString() ?? string.Empty] = entry.Value;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
var type = parameters.GetType();
|
||||
if (type.IsPrimitive || parameters is string or decimal)
|
||||
throw new ArgumentException(
|
||||
$"Script call parameters must be an object or dictionary, not {type.Name}.",
|
||||
nameof(parameters));
|
||||
|
||||
var bag = new Dictionary<string, object?>();
|
||||
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (prop.GetIndexParameters().Length > 0) continue;
|
||||
bag[prop.Name] = prop.GetValue(parameters);
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Typed wrapper around script parameters. Implements IReadOnlyDictionary for backward
|
||||
/// compatibility (Parameters["key"]) and adds Get<T>() for typed access with
|
||||
/// clear error messages.
|
||||
/// </summary>
|
||||
public class ScriptParameters : IReadOnlyDictionary<string, object?>
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _inner;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScriptParameters"/> class with the specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The underlying parameter dictionary.</param>
|
||||
public ScriptParameters(IReadOnlyDictionary<string, object?> parameters)
|
||||
{
|
||||
_inner = parameters ?? throw new ArgumentNullException(nameof(parameters));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScriptParameters"/> class with an empty parameter dictionary.
|
||||
/// </summary>
|
||||
public ScriptParameters() : this(new Dictionary<string, object?>()) { }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a parameter value with typed conversion.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Get<int>("key")</c> — throws if missing, null, or unconvertible.</item>
|
||||
/// <item><c>Get<int?>("key")</c> — returns null if the parameter is missing or null;
|
||||
/// throws if it is present but holds an unconvertible value.</item>
|
||||
/// <item><c>Get<int[]>("key")</c> — converts list to typed array; throws on first bad element.</item>
|
||||
/// <item><c>Get<List<int>>("key")</c> — converts list to typed List; throws on first bad element.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The target type for the parameter value.</typeparam>
|
||||
/// <param name="key">The parameter key.</param>
|
||||
/// <returns>The converted parameter value.</returns>
|
||||
public T Get<T>(string key)
|
||||
{
|
||||
var targetType = typeof(T);
|
||||
|
||||
// Array types: int[], string[], etc.
|
||||
if (targetType.IsArray && targetType != typeof(string))
|
||||
{
|
||||
var elementType = targetType.GetElementType()!;
|
||||
return (T)(object)ConvertToArray(key, elementType);
|
||||
}
|
||||
|
||||
// List<T> types
|
||||
if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(List<>))
|
||||
{
|
||||
var elementType = targetType.GetGenericArguments()[0];
|
||||
return (T)ConvertToList(key, elementType);
|
||||
}
|
||||
|
||||
// Nullable<T> types: int?, bool?, etc.
|
||||
var underlyingType = Nullable.GetUnderlyingType(targetType);
|
||||
if (underlyingType != null)
|
||||
{
|
||||
return GetNullable<T>(key, underlyingType);
|
||||
}
|
||||
|
||||
// Non-nullable scalar types
|
||||
return GetRequired<T>(key, targetType);
|
||||
}
|
||||
|
||||
private T GetRequired<T>(string key, Type targetType)
|
||||
{
|
||||
if (!_inner.TryGetValue(key, out var value))
|
||||
throw new ScriptParameterException($"Parameter '{key}' not found");
|
||||
|
||||
if (value is null)
|
||||
throw new ScriptParameterException($"Parameter '{key}' value is null");
|
||||
|
||||
return (T)ConvertScalar(value, targetType, key);
|
||||
}
|
||||
|
||||
private T GetNullable<T>(string key, Type underlyingType)
|
||||
{
|
||||
// Absent or explicitly-null parameter — the caller did not supply a value.
|
||||
if (!_inner.TryGetValue(key, out var value) || value is null)
|
||||
return default!; // null for Nullable<T>
|
||||
|
||||
// A parameter that is *present but non-null* must be convertible. A value
|
||||
// that cannot be converted is a caller/script bug, not "not supplied":
|
||||
// throw with a descriptive message rather than silently returning null
|
||||
// (which a script would misread as absent). This mirrors Get<T>() and the
|
||||
// array/list element paths. See Commons-003.
|
||||
var converted = ConvertScalar(value, underlyingType, key);
|
||||
return (T)converted;
|
||||
}
|
||||
|
||||
private Array ConvertToArray(string key, Type elementType)
|
||||
{
|
||||
var list = GetSourceList(key);
|
||||
var array = Array.CreateInstance(elementType, list.Count);
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var item = list[i];
|
||||
if (item is null)
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' element at index {i} is null");
|
||||
|
||||
try
|
||||
{
|
||||
array.SetValue(ConvertScalar(item, elementType, key), i);
|
||||
}
|
||||
catch (ScriptParameterException)
|
||||
{
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}");
|
||||
}
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private object ConvertToList(string key, Type elementType)
|
||||
{
|
||||
var list = GetSourceList(key);
|
||||
var listType = typeof(List<>).MakeGenericType(elementType);
|
||||
var result = (IList)Activator.CreateInstance(listType, list.Count)!;
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var item = list[i];
|
||||
if (item is null)
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' element at index {i} is null");
|
||||
|
||||
try
|
||||
{
|
||||
result.Add(ConvertScalar(item, elementType, key));
|
||||
}
|
||||
catch (ScriptParameterException)
|
||||
{
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' element at index {i} with value '{item}' could not be parsed as {elementType.Name}");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private IList GetSourceList(string key)
|
||||
{
|
||||
if (!_inner.TryGetValue(key, out var value))
|
||||
throw new ScriptParameterException($"Parameter '{key}' not found");
|
||||
|
||||
if (value is null)
|
||||
throw new ScriptParameterException($"Parameter '{key}' value is null");
|
||||
|
||||
if (value is IList list)
|
||||
return list;
|
||||
|
||||
throw new ScriptParameterException($"Parameter '{key}' is not a list or array");
|
||||
}
|
||||
|
||||
private static object ConvertScalar(object value, Type targetType, string key)
|
||||
{
|
||||
// Direct type match
|
||||
if (targetType.IsInstanceOfType(value))
|
||||
return value;
|
||||
|
||||
// Unwrap JsonElement (from JSON deserialization of List<object?> / Dictionary<string, object?>)
|
||||
if (value is JsonElement je)
|
||||
return ConvertJsonElement(je, targetType, key);
|
||||
|
||||
// string target — use ToString
|
||||
if (targetType == typeof(string))
|
||||
return value.ToString() ?? string.Empty;
|
||||
|
||||
// DateTime from string
|
||||
if (targetType == typeof(DateTime) && value is string dateStr)
|
||||
{
|
||||
if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var dt))
|
||||
return dt;
|
||||
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' with value '{value}' could not be parsed as DateTime");
|
||||
}
|
||||
|
||||
// Numeric and other IConvertible conversions
|
||||
try
|
||||
{
|
||||
return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' with value '{value}' could not be parsed as {targetType.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
private static object ConvertJsonElement(JsonElement element, Type targetType, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (targetType == typeof(bool) && element.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
return element.GetBoolean();
|
||||
if (targetType == typeof(int) && element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetInt32();
|
||||
if (targetType == typeof(long) && element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetInt64();
|
||||
if (targetType == typeof(float) && element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetSingle();
|
||||
if (targetType == typeof(double) && element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetDouble();
|
||||
if (targetType == typeof(string) && element.ValueKind == JsonValueKind.String)
|
||||
return element.GetString()!;
|
||||
if (targetType == typeof(string))
|
||||
return element.ToString();
|
||||
if (targetType == typeof(DateTime) && element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTime.TryParse(element.GetString(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var dt))
|
||||
return dt;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException or InvalidOperationException)
|
||||
{
|
||||
// Fall through to error
|
||||
}
|
||||
|
||||
throw new ScriptParameterException(
|
||||
$"Parameter '{key}' with value '{element}' could not be parsed as {targetType.Name}");
|
||||
}
|
||||
|
||||
// IReadOnlyDictionary<string, object?> implementation
|
||||
/// <summary>
|
||||
/// Gets the value associated with the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key">The parameter key.</param>
|
||||
/// <returns>The parameter value, or null if the key is not found.</returns>
|
||||
public object? this[string key] => _inner[key];
|
||||
/// <summary>
|
||||
/// Gets the collection of parameter keys.
|
||||
/// </summary>
|
||||
public IEnumerable<string> Keys => _inner.Keys;
|
||||
/// <summary>
|
||||
/// Gets the collection of parameter values.
|
||||
/// </summary>
|
||||
public IEnumerable<object?> Values => _inner.Values;
|
||||
/// <summary>
|
||||
/// Gets the number of parameters.
|
||||
/// </summary>
|
||||
public int Count => _inner.Count;
|
||||
/// <summary>
|
||||
/// Determines whether the parameters contain the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key">The key to locate.</param>
|
||||
/// <returns>True if the key is found; false otherwise.</returns>
|
||||
public bool ContainsKey(string key) => _inner.ContainsKey(key);
|
||||
/// <summary>
|
||||
/// Attempts to get the value associated with the specified key.
|
||||
/// </summary>
|
||||
/// <param name="key">The key to locate.</param>
|
||||
/// <param name="value">The value associated with the key if found; otherwise null.</param>
|
||||
/// <returns>True if the key is found; false otherwise.</returns>
|
||||
public bool TryGetValue(string key, out object? value) => _inner.TryGetValue(key, out value);
|
||||
/// <summary>
|
||||
/// Returns an enumerator that iterates through the parameters.
|
||||
/// </summary>
|
||||
/// <returns>An enumerator for the parameters.</returns>
|
||||
public IEnumerator<KeyValuePair<string, object?>> GetEnumerator() => _inner.GetEnumerator();
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown by <see cref="ScriptParameters.Get{T}"/> when a parameter
|
||||
/// cannot be retrieved or converted to the requested type.
|
||||
/// </summary>
|
||||
public class ScriptParameterException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScriptParameterException"/> class with a specified error message.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message that explains the reason for the exception.</param>
|
||||
public ScriptParameterException(string message) : base(message) { }
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScriptParameterException"/> class with a specified error message and inner exception.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message that explains the reason for the exception.</param>
|
||||
/// <param name="innerException">The exception that is the cause of the current exception.</param>
|
||||
public ScriptParameterException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context exposed to on-trigger scripts via <c>Alarm</c>. Lets scripts
|
||||
/// branch on the firing severity — e.g., page on-call for HiHi/LoLo but only
|
||||
/// email the day shift for Hi/Lo. Always present when an on-trigger script
|
||||
/// runs; <see cref="Level"/> is <see cref="AlarmLevel.None"/> for binary
|
||||
/// trigger types.
|
||||
/// </summary>
|
||||
public sealed class AlarmContext
|
||||
{
|
||||
/// <summary>Name of the alarm that fired.</summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>Severity level of the alarm; <see cref="AlarmLevel.None"/> for binary trigger types.</summary>
|
||||
public AlarmLevel Level { get; init; } = AlarmLevel.None;
|
||||
/// <summary>Operator-assigned priority of the alarm.</summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-band operator message configured on the HiLo alarm, or empty for
|
||||
/// binary trigger types and bands without a message.
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Where a compiled script sits in the composition tree. Computed at
|
||||
/// flattening time and seeded into the script's globals at execution time
|
||||
/// so <c>Attributes["X"]</c> / <c>Parent.X</c> can prepend the right
|
||||
/// path-prefix when delegating to the existing flat
|
||||
/// <c>Instance.GetAttribute</c> / <c>Instance.SetAttribute</c> /
|
||||
/// <c>Instance.CallScript</c> APIs.
|
||||
/// </summary>
|
||||
public sealed record ScriptScope(string SelfPath, string? ParentPath)
|
||||
{
|
||||
/// <summary>Scope for a script directly on the root template (no compositions).</summary>
|
||||
public static readonly ScriptScope Root = new("", null);
|
||||
|
||||
/// <summary>Gets a value indicating whether this script has a parent composition path.</summary>
|
||||
public bool HasParent => ParentPath != null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) M6 Bundle E (T6) — point-in-time snapshot of the site-local
|
||||
/// SQLite audit-log queue health, surfaced on
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport"/> as
|
||||
/// <c>SiteAuditBacklog</c> and refreshed periodically by the
|
||||
/// <c>SiteAuditBacklogReporter</c> hosted service.
|
||||
/// </summary>
|
||||
/// <param name="PendingCount">
|
||||
/// Number of rows currently in
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/> — i.e.
|
||||
/// not yet acknowledged by central via either the push-telemetry or
|
||||
/// reconciliation-pull paths. A persistently non-zero value with rising
|
||||
/// <see cref="OldestPendingUtc"/> indicates the site→central drain isn't
|
||||
/// keeping up.
|
||||
/// </param>
|
||||
/// <param name="OldestPendingUtc">
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent.OccurredAtUtc"/> of
|
||||
/// the oldest Pending row, or <c>null</c> if the queue is empty. Used by ops
|
||||
/// to compute backlog age without a separate query.
|
||||
/// </param>
|
||||
/// <param name="OnDiskBytes">
|
||||
/// Size of the SQLite file on disk in bytes, or <c>0</c> if the writer is
|
||||
/// running against an in-memory database. Mirrors the 7-day retention
|
||||
/// invariant (alog.md §10) — a steady file-size growth past the retention
|
||||
/// window points at a stuck purge or a stuck forwarder.
|
||||
/// </param>
|
||||
public sealed record SiteAuditBacklogSnapshot(
|
||||
int PendingCount,
|
||||
DateTime? OldestPendingUtc,
|
||||
long OnDiskBytes);
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Operational state of one cached call as seen by the site, carried on the
|
||||
/// combined <c>CachedCallTelemetry</c> packet (Audit Log #23 / M3) and persisted
|
||||
/// at central as the <c>SiteCalls</c> row mirroring the call's status.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One row per <see cref="TrackedOperationId"/> at central; ingest is
|
||||
/// insert-if-not-exists then upsert-on-newer-status (monotonic — never rolls
|
||||
/// back). The site remains the source of truth — this record is the
|
||||
/// "eventually-consistent mirror" the central UI's Site Calls page reads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="TrackedOperationId">Idempotency key shared with the audit row.</param>
|
||||
/// <param name="Channel">
|
||||
/// Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>. String form
|
||||
/// (not the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditChannel"/> enum) so the
|
||||
/// record serialises identically across SQL / gRPC / JSON boundaries.
|
||||
/// </param>
|
||||
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
|
||||
/// <param name="SourceSite">Site id that submitted the cached call.</param>
|
||||
/// <param name="SourceNode">
|
||||
/// The cluster node on which the cached call was emitted — <c>node-a</c> / <c>node-b</c>
|
||||
/// for site rows (qualified by <paramref name="SourceSite"/>), <c>central-a</c> /
|
||||
/// <c>central-b</c> for central-originated rows. Stamped by the emitting node from
|
||||
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that has since
|
||||
/// been retired don't block ingest.
|
||||
/// </param>
|
||||
/// <param name="Status">
|
||||
/// Lifecycle status — string form of <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditStatus"/>:
|
||||
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
|
||||
/// <c>Failed</c>, <c>Parked</c>, <c>Discarded</c>.
|
||||
/// </param>
|
||||
/// <param name="RetryCount">Number of dispatch attempts so far; 0 prior to first attempt.</param>
|
||||
/// <param name="LastError">Most recent error message; null when no failures have occurred.</param>
|
||||
/// <param name="HttpStatus">Most recent HTTP status code (API calls only); null otherwise.</param>
|
||||
/// <param name="CreatedAtUtc">UTC timestamp the cached call was first submitted.</param>
|
||||
/// <param name="UpdatedAtUtc">UTC timestamp of the latest status mutation.</param>
|
||||
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
|
||||
public sealed record SiteCallOperational(
|
||||
TrackedOperationId TrackedOperationId,
|
||||
string Channel,
|
||||
string Target,
|
||||
string SourceSite,
|
||||
string? SourceNode,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
int? HttpStatus,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime UpdatedAtUtc,
|
||||
DateTime? TerminalAtUtc);
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.WW.ScadaBridge.Commons.Tests")]
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Monitors a heartbeat tag subscription for staleness. If no value is received
|
||||
/// within <see cref="MaxSilence"/>, the <see cref="Stale"/> event fires.
|
||||
/// Composable into any IDataConnection adapter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thread-safe: <see cref="Start"/>, <see cref="OnValueReceived"/> and <see cref="Stop"/>
|
||||
/// may be called from any thread and race the internal timer callback. Each call to
|
||||
/// <see cref="Start"/> or <see cref="OnValueReceived"/> begins a new monitoring period
|
||||
/// identified by a generation token; a timer callback only raises <see cref="Stale"/>
|
||||
/// if it still belongs to the current period. A fresh value, a restart, or a
|
||||
/// <see cref="Stop"/> arriving while a previous-period callback is in flight bumps the
|
||||
/// generation, so that callback observes the mismatch and declines to fire — no spurious
|
||||
/// staleness signal is emitted after the period it was scheduled for has ended.
|
||||
/// </remarks>
|
||||
public sealed class StaleTagMonitor : IDisposable
|
||||
{
|
||||
private readonly TimeSpan _maxSilence;
|
||||
private readonly object _gate = new();
|
||||
private Timer? _timer;
|
||||
|
||||
/// <summary>
|
||||
/// Monotonically increasing token identifying the current monitoring period.
|
||||
/// Bumped on every <see cref="Start"/>, <see cref="OnValueReceived"/> and
|
||||
/// <see cref="Stop"/> so that a timer callback scheduled for an earlier period
|
||||
/// can detect that it is stale and decline to fire.
|
||||
/// </summary>
|
||||
private long _generation;
|
||||
|
||||
/// <summary>Initializes a new <see cref="StaleTagMonitor"/> that fires <see cref="Stale"/> if no value is received within <paramref name="maxSilence"/>.</summary>
|
||||
/// <param name="maxSilence">The maximum time with no received value before the <see cref="Stale"/> event fires; must be positive.</param>
|
||||
public StaleTagMonitor(TimeSpan maxSilence)
|
||||
{
|
||||
if (maxSilence <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSilence), "MaxSilence must be positive.");
|
||||
_maxSilence = maxSilence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires when no value has been received within <see cref="MaxSilence"/>.
|
||||
/// Fires once per stale period — resets after <see cref="OnValueReceived"/> is called.
|
||||
/// </summary>
|
||||
public event Action? Stale;
|
||||
|
||||
/// <summary>Gets the maximum silence interval after which the <see cref="Stale"/> event fires.</summary>
|
||||
public TimeSpan MaxSilence => _maxSilence;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only seam invoked by the timer callback after it has been entered but
|
||||
/// before it acquires the synchronization gate. Allows a test to deterministically
|
||||
/// interleave a <see cref="Stop"/> / <see cref="OnValueReceived"/> with an in-flight
|
||||
/// callback to exercise the stale-fire race. Never set in production.
|
||||
/// </summary>
|
||||
internal Action? CallbackEnteredHook { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Start monitoring. The timer begins counting from now.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_generation++;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(OnTimerElapsed, _generation, _maxSilence, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal that a value was received. Resets the stale timer.
|
||||
/// </summary>
|
||||
public void OnValueReceived()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// No active monitoring — nothing to reset.
|
||||
if (_timer is null)
|
||||
return;
|
||||
|
||||
// Bump the generation: any timer callback for the previous period that
|
||||
// has already been entered will see a generation mismatch and decline to
|
||||
// raise Stale. The timer is recreated rather than re-armed with
|
||||
// Change(...) so the new callback carries the new generation token.
|
||||
_generation++;
|
||||
_timer.Dispose();
|
||||
_timer = new Timer(OnTimerElapsed, _generation, _maxSilence, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop monitoring and dispose the timer.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// Bumping the generation invalidates any in-flight callback so a stopped
|
||||
// monitor cannot deliver a Stale signal.
|
||||
_generation++;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops monitoring and disposes the internal timer.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void OnTimerElapsed(object? state)
|
||||
{
|
||||
var scheduledGeneration = (long)state!;
|
||||
|
||||
CallbackEnteredHook?.Invoke();
|
||||
|
||||
// Only fire if this callback still represents the current period. The check
|
||||
// and the generation bump happen under the gate, so a concurrent
|
||||
// OnValueReceived / Stop / Start either completes before this guard (its
|
||||
// generation bump makes this callback decline) or serializes after it.
|
||||
lock (_gate)
|
||||
{
|
||||
if (_generation != scheduledGeneration)
|
||||
return;
|
||||
|
||||
// Consume this period so a duplicate callback for the same generation
|
||||
// cannot fire twice; the next Start/OnValueReceived issues a new token.
|
||||
_generation++;
|
||||
}
|
||||
|
||||
Stale?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed identifier for a cached outbound operation
|
||||
/// (<c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c>) — the unified
|
||||
/// tracking handle introduced by Audit Log #23 (M3). The same id is the
|
||||
/// idempotency key end-to-end: it is stamped on every <c>AuditLog</c> row
|
||||
/// produced for the operation's lifecycle (CachedSubmit → ApiCallCached /
|
||||
/// DbWriteCached × N attempts → CachedResolve) and is the PK on the central
|
||||
/// <c>SiteCalls</c> row that mirrors the operation's operational state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The struct wraps a <see cref="Guid"/> so it serialises identically to a
|
||||
/// 36-character "D"-format string anywhere the existing GUID conventions are
|
||||
/// used (gRPC strings, JSON, SQL TEXT columns). <see cref="ToString"/> returns
|
||||
/// the lower-case 8-4-4-4-12 form unconditionally; never the brace- / parens-
|
||||
/// wrapped variants — central ingest parses with <see cref="Guid.Parse"/>, which
|
||||
/// is format-tolerant but the wire shape is fixed for log readability.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public readonly record struct TrackedOperationId(Guid Value)
|
||||
{
|
||||
/// <summary>Mint a fresh id at the call site (script-thread safe).</summary>
|
||||
public static TrackedOperationId New() => new(Guid.NewGuid());
|
||||
|
||||
/// <summary>
|
||||
/// Parse a serialised id back into the strong type. Throws when the input
|
||||
/// is not a valid GUID — callers crossing untrusted boundaries should use
|
||||
/// <see cref="TryParse"/> instead.
|
||||
/// </summary>
|
||||
/// <param name="s">GUID string to parse.</param>
|
||||
public static TrackedOperationId Parse(string s) => new(Guid.Parse(s));
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to parse a serialised id. Returns <c>false</c> for null, empty
|
||||
/// or non-GUID input; <paramref name="result"/> is <c>default</c> on
|
||||
/// failure.
|
||||
/// </summary>
|
||||
/// <param name="s">GUID string to parse, or null.</param>
|
||||
/// <param name="result">Parsed value on success; default on failure.</param>
|
||||
public static bool TryParse(string? s, out TrackedOperationId result)
|
||||
{
|
||||
if (Guid.TryParse(s, out var g))
|
||||
{
|
||||
result = new TrackedOperationId(g);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => Value.ToString("D");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Site-local snapshot of a cached operation's tracking state, returned by the
|
||||
/// <c>Tracking.Status(TrackedOperationId)</c> script API (Audit Log #23 / M3).
|
||||
/// </summary>
|
||||
/// <param name="Id">Tracking handle returned by <c>CachedCall</c>/<c>CachedWrite</c>.</param>
|
||||
/// <param name="Kind">
|
||||
/// Operation category — <c>"ApiCallCached"</c> or <c>"DbWriteCached"</c> — mirroring
|
||||
/// the <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind"/> per-attempt vocabulary.
|
||||
/// </param>
|
||||
/// <param name="TargetSummary">
|
||||
/// Human-readable target (e.g. <c>"ERP.GetOrder"</c> or <c>"WarehouseDb"</c>); may be
|
||||
/// null for early-lifecycle rows recorded before the target was resolved.
|
||||
/// </param>
|
||||
/// <param name="Status">
|
||||
/// Lifecycle status — one of <c>Submitted</c>, <c>Forwarded</c>, <c>Retrying</c>,
|
||||
/// <c>Attempted</c>, <c>Delivered</c>, <c>Failed</c>, <c>Parked</c>, <c>Discarded</c>.
|
||||
/// </param>
|
||||
/// <param name="RetryCount">Number of attempts made; 0 prior to first dispatch.</param>
|
||||
/// <param name="LastError">Most recent error message; null while non-terminal-and-no-failures.</param>
|
||||
/// <param name="HttpStatus">Most recent HTTP status code where applicable; null otherwise.</param>
|
||||
/// <param name="CreatedAtUtc">UTC timestamp the tracking row was first recorded.</param>
|
||||
/// <param name="UpdatedAtUtc">UTC timestamp of the latest status mutation.</param>
|
||||
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
|
||||
/// <param name="SourceInstanceId">Instance id that issued the cached call, when known.</param>
|
||||
/// <param name="SourceScript">Script that issued the cached call, when known.</param>
|
||||
/// <param name="SourceNode">
|
||||
/// Cluster node that submitted the cached call (e.g. <c>"node-a"</c> /
|
||||
/// <c>"node-b"</c>), captured at enqueue time. Null on rows persisted before
|
||||
/// the SourceNode stamping migration; stamping itself is wired in a later task.
|
||||
/// Commons-023: trailing-optional with a <c>= null</c> default, matching the
|
||||
/// SourceNode rollout convention now used on <c>SiteCallSummary</c>,
|
||||
/// <c>SiteCallDetail</c>, <c>NotificationSummary</c> and <c>NotificationDetail</c>
|
||||
/// — so existing positional construction sites keep compiling as new
|
||||
/// optional fields land on this record.
|
||||
/// </param>
|
||||
public sealed record TrackingStatusSnapshot(
|
||||
TrackedOperationId Id,
|
||||
string Kind,
|
||||
string? TargetSummary,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
int? HttpStatus,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime UpdatedAtUtc,
|
||||
DateTime? TerminalAtUtc,
|
||||
string? SourceInstanceId,
|
||||
string? SourceScript,
|
||||
string? SourceNode = null);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed record BundleManifest(
|
||||
int BundleFormatVersion,
|
||||
string SchemaVersion,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string SourceEnvironment,
|
||||
string ExportedBy,
|
||||
string ScadaBridgeVersion,
|
||||
string ContentHash,
|
||||
EncryptionMetadata? Encryption,
|
||||
BundleSummary Summary,
|
||||
IReadOnlyList<ManifestContentEntry> Contents);
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed class BundleSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Commons-016: legacy per-session lockout threshold (kept on this type for the
|
||||
/// shim <see cref="Locked"/> getter). The authoritative, server-side per-bundle
|
||||
/// counter is bounded by <c>TransportOptions.MaxUnlockAttemptsPerSession</c>
|
||||
/// (default also <c>3</c>) and is what <c>BundleImporter.LoadAsync</c> consults.
|
||||
/// This constant exists so the comparison in <see cref="Locked"/> uses a named
|
||||
/// symbol that a security review can grep for, rather than a literal <c>3</c>.
|
||||
/// </summary>
|
||||
public const int MaxUnlockAttempts = 3;
|
||||
|
||||
/// <summary>Unique identifier for this import session.</summary>
|
||||
public Guid SessionId { get; init; }
|
||||
/// <summary>Parsed manifest from the uploaded bundle.</summary>
|
||||
public BundleManifest Manifest { get; init; } = null!;
|
||||
/// <summary>Decrypted bundle content bytes; empty until the bundle is successfully unlocked.</summary>
|
||||
public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
|
||||
/// <summary>UTC timestamp after which this session is considered expired and must be re-uploaded.</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
/// <summary>
|
||||
/// T-003: legacy per-session unlock-attempt counter. The unlock lockout is now
|
||||
/// owned by <c>IBundleSessionStore</c> and keyed by <c>BundleManifest.ContentHash</c>
|
||||
/// so retries from a second tab / CLI caller share the counter. A successful
|
||||
/// <c>LoadAsync</c> never increments this — it stays 0 on every opened session,
|
||||
/// and <see cref="Locked"/> is unreachable on a session returned by the store.
|
||||
/// Retained as a compatibility shim for callers/tests that still set it directly.
|
||||
/// </summary>
|
||||
public int FailedUnlockAttempts { get; set; }
|
||||
/// <summary>
|
||||
/// T-003 legacy: always <c>false</c> on a session returned by <c>LoadAsync</c>
|
||||
/// because lockout enforcement moved server-side; see <see cref="FailedUnlockAttempts"/>.
|
||||
/// The threshold is the named <see cref="MaxUnlockAttempts"/> constant (default 3).
|
||||
/// </summary>
|
||||
public bool Locked => FailedUnlockAttempts >= MaxUnlockAttempts;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed record BundleSummary(
|
||||
int Templates,
|
||||
int TemplateFolders,
|
||||
int SharedScripts,
|
||||
int ExternalSystems,
|
||||
int DbConnections,
|
||||
int NotificationLists,
|
||||
int SmtpConfigs,
|
||||
int ApiKeys,
|
||||
int ApiMethods);
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// AES-GCM encryption envelope metadata for a bundle's content payload. Carried on
|
||||
/// the bundle manifest (plaintext) so the importer can derive the per-bundle key and
|
||||
/// initialise the cipher without prior knowledge of the passphrase.
|
||||
/// <para>
|
||||
/// Commons-015: invariants are enforced in the constructor so a malformed envelope
|
||||
/// (unknown algorithm, unsupported KDF, weak iteration count, null salt/IV) is
|
||||
/// rejected at the type boundary rather than failing inside
|
||||
/// <see cref="System.Security.Cryptography.AesGcm"/> with a misleading exception.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record EncryptionMetadata
|
||||
{
|
||||
/// <summary>The only AES symmetric algorithm the bundle format supports.</summary>
|
||||
public const string SupportedAlgorithm = "AES-256-GCM";
|
||||
|
||||
/// <summary>The only key-derivation function the bundle format supports.</summary>
|
||||
public const string SupportedKdf = "PBKDF2-SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2 iteration-count floor — OWASP's documented minimum. The Transport design
|
||||
/// doc specifies <c>600_000</c> as the production value; this constant is the hard
|
||||
/// reject threshold below which the envelope is treated as malformed.
|
||||
/// </summary>
|
||||
public const int MinPbkdf2Iterations = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2 iteration-count ceiling — guards against a hostile bundle declaring an
|
||||
/// absurd iteration count that would burn CPU on every unlock attempt.
|
||||
/// </summary>
|
||||
public const int MaxPbkdf2Iterations = 10_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="EncryptionMetadata"/>. Each argument is validated
|
||||
/// against the documented contract; invalid values throw <see cref="ArgumentException"/>
|
||||
/// naming the offending field.
|
||||
/// </summary>
|
||||
/// <param name="Algorithm">Symmetric algorithm name; must equal <see cref="SupportedAlgorithm"/>.</param>
|
||||
/// <param name="Kdf">Key-derivation function name; must equal <see cref="SupportedKdf"/>.</param>
|
||||
/// <param name="Iterations">PBKDF2 iteration count; must lie in [<see cref="MinPbkdf2Iterations"/>, <see cref="MaxPbkdf2Iterations"/>].</param>
|
||||
/// <param name="SaltB64">Base64-encoded PBKDF2 salt; must be non-null (may be empty for the seed pattern used by BundleSerializer.Pack).</param>
|
||||
/// <param name="IvB64">Base64-encoded AES-GCM IV; must be non-null (may be empty for the seed pattern used by BundleSerializer.Pack).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when any field violates the documented contract.</exception>
|
||||
public EncryptionMetadata(string Algorithm, string Kdf, int Iterations, string SaltB64, string IvB64)
|
||||
{
|
||||
if (!string.Equals(Algorithm, SupportedAlgorithm, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(Algorithm)} must be '{SupportedAlgorithm}'; got '{Algorithm}'.",
|
||||
nameof(Algorithm));
|
||||
}
|
||||
|
||||
if (!string.Equals(Kdf, SupportedKdf, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(Kdf)} must be '{SupportedKdf}'; got '{Kdf}'.",
|
||||
nameof(Kdf));
|
||||
}
|
||||
|
||||
if (Iterations < MinPbkdf2Iterations || Iterations > MaxPbkdf2Iterations)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(Iterations)} must be between {MinPbkdf2Iterations} and {MaxPbkdf2Iterations}; got {Iterations}.",
|
||||
nameof(Iterations));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(SaltB64);
|
||||
ArgumentNullException.ThrowIfNull(IvB64);
|
||||
|
||||
this.Algorithm = Algorithm;
|
||||
this.Kdf = Kdf;
|
||||
this.Iterations = Iterations;
|
||||
this.SaltB64 = SaltB64;
|
||||
this.IvB64 = IvB64;
|
||||
}
|
||||
|
||||
/// <summary>Symmetric algorithm name (always <see cref="SupportedAlgorithm"/>).</summary>
|
||||
public string Algorithm { get; init; }
|
||||
|
||||
/// <summary>Key-derivation function name (always <see cref="SupportedKdf"/>).</summary>
|
||||
public string Kdf { get; init; }
|
||||
|
||||
/// <summary>PBKDF2 iteration count.</summary>
|
||||
public int Iterations { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded PBKDF2 salt.</summary>
|
||||
public string SaltB64 { get; init; }
|
||||
|
||||
/// <summary>Base64-encoded AES-GCM IV.</summary>
|
||||
public string IvB64 { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed record ExportSelection(
|
||||
IReadOnlyList<int> TemplateIds,
|
||||
IReadOnlyList<int> SharedScriptIds,
|
||||
IReadOnlyList<int> ExternalSystemIds,
|
||||
IReadOnlyList<int> DatabaseConnectionIds,
|
||||
IReadOnlyList<int> NotificationListIds,
|
||||
IReadOnlyList<int> SmtpConfigurationIds,
|
||||
IReadOnlyList<int> ApiKeyIds,
|
||||
IReadOnlyList<int> ApiMethodIds,
|
||||
bool IncludeDependencies);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public enum ConflictKind { Identical, Modified, New, Blocker }
|
||||
|
||||
public sealed record ImportPreviewItem(
|
||||
string EntityType,
|
||||
string Name,
|
||||
int? ExistingVersion,
|
||||
int? IncomingVersion,
|
||||
ConflictKind Kind,
|
||||
string? FieldDiffJson,
|
||||
string? BlockerReason);
|
||||
|
||||
public sealed record ImportPreview(
|
||||
Guid SessionId,
|
||||
IReadOnlyList<ImportPreviewItem> Items);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public enum ResolutionAction { Add, Overwrite, Skip, Rename }
|
||||
|
||||
public sealed record ImportResolution(
|
||||
string EntityType,
|
||||
string Name,
|
||||
ResolutionAction Action,
|
||||
string? RenameTo);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed record ImportResult(
|
||||
Guid BundleImportId,
|
||||
int Added,
|
||||
int Overwritten,
|
||||
int Skipped,
|
||||
int Renamed,
|
||||
IReadOnlyList<int> StaleInstanceIds,
|
||||
string AuditEventCorrelation);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
public sealed record ManifestContentEntry(
|
||||
string Type,
|
||||
string Name,
|
||||
int Version,
|
||||
IReadOnlyList<string> DependsOn);
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Formats attribute values for display. Handles scalar types directly
|
||||
/// and converts arrays/collections to comma-separated strings.
|
||||
/// </summary>
|
||||
public static class ValueFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a value as a string. Returns the value's string representation for
|
||||
/// scalars and comma-separated elements for array/collection types.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to format; null returns an empty string.</param>
|
||||
/// <remarks>
|
||||
/// Formatting is <see cref="CultureInfo.InvariantCulture">culture-invariant</see>:
|
||||
/// numbers and <see cref="DateTime"/> values render the same regardless of the
|
||||
/// server/thread locale. This is required because the formatter feeds non-UI
|
||||
/// contexts (gRPC stream events, logs, diff display) where locale-dependent
|
||||
/// output (decimal separators, date order) would be inconsistent.
|
||||
/// </remarks>
|
||||
public static string FormatDisplayValue(object? value)
|
||||
{
|
||||
if (value is null) return "";
|
||||
if (value is string s) return s;
|
||||
if (value is IFormattable formattable)
|
||||
return formattable.ToString(null, CultureInfo.InvariantCulture) ?? "";
|
||||
|
||||
if (value is IEnumerable enumerable)
|
||||
{
|
||||
return string.Join(",", enumerable.Cast<object?>().Select(FormatElement));
|
||||
}
|
||||
|
||||
return value.ToString() ?? "";
|
||||
}
|
||||
|
||||
private static string FormatElement(object? element) => element switch
|
||||
{
|
||||
null => "",
|
||||
string str => str,
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture) ?? "",
|
||||
_ => element.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user