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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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>&gt;=</c> / <c>&lt;=</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&lt;string, StringValues&gt;</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>&gt;=</c> / <c>&lt;=</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 &lt;
/// 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 &lt; Low &lt; normal-band &lt; High &lt; 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&lt;string, object?&gt;</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&lt;T&gt;() 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&lt;int&gt;("key")</c> — throws if missing, null, or unconvertible.</item>
/// <item><c>Get&lt;int?&gt;("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&lt;int[]&gt;("key")</c> — converts list to typed array; throws on first bad element.</item>
/// <item><c>Get&lt;List&lt;int&gt;&gt;("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() ?? ""
};
}