chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
public sealed class ClusterNode
{
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
public required string NodeId { get; set; }
public required string ClusterId { get; set; }
public required RedundancyRole RedundancyRole { get; set; }
/// <summary>Machine hostname / IP.</summary>
public required string Host { get; set; }
public int OpcUaPort { get; set; } = 4840;
public int DashboardPort { get; set; } = 8081;
/// <summary>
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
/// hostname change would break all client trust.
/// </summary>
public required string ApplicationUri { get; set; }
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
public byte ServiceLevelBase { get; set; } = 200;
/// <summary>
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
/// </summary>
public string? DriverConfigOverridesJson { get; set; }
public bool Enabled { get; set; } = true;
public DateTime? LastSeenAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
// Navigation
public ServerCluster? Cluster { get; set; }
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
public ClusterNodeGenerationState? GenerationState { get; set; }
}

View File

@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
/// </summary>
public sealed class ClusterNodeCredential
{
public Guid CredentialId { get; set; }
public required string NodeId { get; set; }
public required CredentialKind Kind { get; set; }
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
public required string Value { get; set; }
public bool Enabled { get; set; } = true;
public DateTime? RotatedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public ClusterNode? Node { get; set; }
}

View File

@@ -0,0 +1,26 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
/// 2-node cluster track independently per decision #84.
/// </summary>
public sealed class ClusterNodeGenerationState
{
public required string NodeId { get; set; }
public long? CurrentGenerationId { get; set; }
public DateTime? LastAppliedAt { get; set; }
public NodeApplyStatus? LastAppliedStatus { get; set; }
public string? LastAppliedError { get; set; }
/// <summary>Updated on every poll for liveness detection.</summary>
public DateTime? LastSeenAt { get; set; }
public ClusterNode? Node { get; set; }
public ConfigGeneration? CurrentGeneration { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
/// </summary>
public sealed class ConfigAuditLog
{
public long AuditId { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public required string Principal { get; set; }
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
public required string EventType { get; set; }
public string? ClusterId { get; set; }
public string? NodeId { get; set; }
public long? GenerationId { get; set; }
public string? DetailsJson { get; set; }
}

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Atomic, immutable snapshot of one cluster's configuration.
/// Per decision #82 — cluster-scoped, not fleet-scoped.
/// </summary>
public sealed class ConfigGeneration
{
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
public long GenerationId { get; set; }
public required string ClusterId { get; set; }
public required GenerationStatus Status { get; set; }
public long? ParentGenerationId { get; set; }
public DateTime? PublishedAt { get; set; }
public string? PublishedBy { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public ServerCluster? Cluster { get; set; }
public ConfigGeneration? Parent { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
public sealed class Device
{
public Guid DeviceRowId { get; set; }
public long GenerationId { get; set; }
public required string DeviceId { get; set; }
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
public required string DriverInstanceId { get; set; }
public required string Name { get; set; }
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
public required string DeviceConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,61 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-host connectivity snapshot the Server publishes for each driver's
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
/// rows, not 3, because each server node owns its own runtime view.
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
/// </para>
/// <para>
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
/// rather keep the status row than drop it. The Admin-side service left-joins on
/// NodeId when presenting rows.
/// </para>
/// </remarks>
public sealed class DriverHostStatus
{
/// <summary>Server node that's running the driver.</summary>
public required string NodeId { get; set; }
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
/// a display string.
/// </summary>
public required string HostName { get; set; }
public DriverHostState State { get; set; } = DriverHostState.Unknown;
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
public DateTime StateChangedUtc { get; set; }
/// <summary>
/// Advances on every publisher heartbeat — the Admin UI uses
/// <c>now - LastSeenUtc &gt; threshold</c> to flag rows whose owning Server has
/// stopped reporting (crashed, network-partitioned, etc.), independent of
/// <see cref="State"/>.
/// </summary>
public DateTime LastSeenUtc { get; set; }
/// <summary>
/// Optional human-readable detail populated when <see cref="State"/> is
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
/// driver's probe. Null for Running / Stopped / Unknown transitions.
/// </summary>
public string? Detail { get; set; }
}

View File

@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
public sealed class DriverInstance
{
public Guid DriverInstanceRowId { get; set; }
public long GenerationId { get; set; }
public required string DriverInstanceId { get; set; }
public required string ClusterId { get; set; }
/// <summary>
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
/// </summary>
public required string NamespaceId { get; set; }
public required string Name { get; set; }
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
public required string DriverType { get; set; }
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
public required string DriverConfig { get; set; }
/// <summary>
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
/// <code>
/// {
/// "bulkheadMaxConcurrent": 16,
/// "bulkheadMaxQueue": 64,
/// "capabilityPolicies": {
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
/// }
/// }
/// </code>
/// Parsed at startup by <c>DriverResilienceOptionsParser</c>; every key is optional +
/// unrecognised keys are ignored so future shapes land without a migration.
/// </summary>
public string? ResilienceConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Runtime resilience counters the CapabilityInvoker + MemoryTracking + MemoryRecycle
/// surfaces for each <c>(DriverInstanceId, HostName)</c> pair. Separate from
/// <see cref="DriverHostStatus"/> (which owns per-host <i>connectivity</i> state) so a
/// host that's Running but has tripped its breaker or is approaching its memory ceiling
/// shows up distinctly on Admin <c>/hosts</c>.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/implementation/phase-6-1-resilience-and-observability.md</c> §Stream E.1.
/// The Admin UI left-joins this table on DriverHostStatus for display; rows are written
/// by the runtime via a HostedService that samples the tracker at a configurable
/// interval (default 5 s) — writes are non-critical, a missed sample is tolerated.
/// </remarks>
public sealed class DriverInstanceResilienceStatus
{
public required string DriverInstanceId { get; set; }
public required string HostName { get; set; }
/// <summary>Most recent time the circuit breaker for this (instance, host) opened; null if never.</summary>
public DateTime? LastCircuitBreakerOpenUtc { get; set; }
/// <summary>Rolling count of consecutive Polly pipeline failures for this (instance, host).</summary>
public int ConsecutiveFailures { get; set; }
/// <summary>Current Polly bulkhead depth (in-flight calls) for this (instance, host).</summary>
public int CurrentBulkheadDepth { get; set; }
/// <summary>Most recent process recycle time (Tier C only; null for in-process tiers).</summary>
public DateTime? LastRecycleUtc { get; set; }
/// <summary>
/// Post-init memory baseline captured by <c>MemoryTracking</c> (median of first
/// BaselineWindow samples). Zero while still warming up.
/// </summary>
public long BaselineFootprintBytes { get; set; }
/// <summary>Most recent footprint sample the tracker saw (steady-state read).</summary>
public long CurrentFootprintBytes { get; set; }
/// <summary>Row last-write timestamp — advances on every sampling tick.</summary>
public DateTime LastSampledUtc { get; set; }
}

View File

@@ -0,0 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
/// #138139 (OPC 40010 Identification fields as first-class columns).
/// </summary>
public sealed class Equipment
{
public Guid EquipmentRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
/// </summary>
public required string EquipmentId { get; set; }
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
public string? DeviceId { get; set; }
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
public required string UnsLineId { get; set; }
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
public required string Name { get; set; }
// Operator-facing / external-system identifiers (decision #116)
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
public required string MachineCode { get; set; }
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
public string? ZTag { get; set; }
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
public string? SAPID { get; set; }
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
// All nullable so equipment can be added before identity is fully captured.
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
public string? EquipmentClassRef { get; set; }
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,68 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child
/// <see cref="EquipmentImportRow"/> table under a batch header; operator reviews + either
/// drops (via <c>DropImportBatch</c>) or finalises (via <c>FinaliseImportBatch</c>) in one
/// bounded transaction. The live <c>Equipment</c> table never sees partial state.
/// </summary>
/// <remarks>
/// <para>User-scoped visibility: the preview modal only shows batches where
/// <see cref="CreatedBy"/> equals the current operator. Prevents accidental
/// cross-operator finalise during concurrent imports. An admin finalise / drop surface
/// can override this — tracked alongside the UI follow-up.</para>
///
/// <para><see cref="FinalisedAtUtc"/> stamps the moment the batch promoted from staging
/// into <c>Equipment</c>. Null = still in staging; non-null = archived / finalised.</para>
/// </remarks>
public sealed class EquipmentImportBatch
{
public Guid Id { get; set; }
public required string ClusterId { get; set; }
public required string CreatedBy { get; set; }
public DateTime CreatedAtUtc { get; set; }
public int RowsStaged { get; set; }
public int RowsAccepted { get; set; }
public int RowsRejected { get; set; }
public DateTime? FinalisedAtUtc { get; set; }
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
}
/// <summary>
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
/// + decision #139 columns from the CSV importer's output + an
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
/// renders.
/// </summary>
public sealed class EquipmentImportRow
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public int LineNumberInFile { get; set; }
public bool IsAccepted { get; set; }
public string? RejectReason { get; set; }
// Required (decision #117)
public required string ZTag { get; set; }
public required string MachineCode { get; set; }
public required string SAPID { get; set; }
public required string EquipmentId { get; set; }
public required string EquipmentUuid { get; set; }
public required string Name { get; set; }
public required string UnsAreaName { get; set; }
public required string UnsLineName { get; set; }
// Optional (decision #139 — OPC 40010 Identification)
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public string? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public EquipmentImportBatch? Batch { get; set; }
}

View File

@@ -0,0 +1,36 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
/// Exists outside generation flow specifically because old generations and disabled equipment can
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
/// </summary>
public sealed class ExternalIdReservation
{
public Guid ReservationId { get; set; }
public required ReservationKind Kind { get; set; }
public required string Value { get; set; }
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>First cluster to publish this reservation.</summary>
public required string ClusterId { get; set; }
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
public required string FirstPublishedBy { get; set; }
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
public DateTime? ReleasedAt { get; set; }
public string? ReleasedBy { get; set; }
public string? ReleaseReason { get; set; }
}

View File

@@ -0,0 +1,56 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Maps an LDAP group to an <see cref="AdminRole"/> for Admin UI access. Optionally scoped
/// to one <see cref="ClusterId"/>; when <see cref="IsSystemWide"/> is true, the grant
/// applies fleet-wide.
/// </summary>
/// <remarks>
/// <para>Per <c>docs/v2/plan.md</c> decisions #105 and #150 — this entity is <b>control-plane
/// only</b>. The OPC UA data-path evaluator does not read these rows; it reads
/// <see cref="NodeAcl"/> joined directly against the session's resolved LDAP group
/// memberships. Collapsing the two would let a user inherit tag permissions via an
/// admin-role claim path never intended as a data-path grant.</para>
///
/// <para>Uniqueness: <c>(LdapGroup, ClusterId)</c> — the same LDAP group may hold
/// different roles on different clusters, but only one row per cluster. A system-wide row
/// (<c>IsSystemWide = true</c>, <c>ClusterId = null</c>) stacks additively with any
/// cluster-scoped rows for the same group.</para>
/// </remarks>
public sealed class LdapGroupRoleMapping
{
/// <summary>Surrogate primary key.</summary>
public Guid Id { get; set; }
/// <summary>
/// LDAP group DN the membership query returns (e.g. <c>cn=fleet-admin,ou=groups,dc=corp,dc=example</c>).
/// Comparison is case-insensitive per LDAP conventions.
/// </summary>
public required string LdapGroup { get; set; }
/// <summary>Admin role this group grants.</summary>
public required AdminRole Role { get; set; }
/// <summary>
/// Cluster the grant applies to; <c>null</c> when <see cref="IsSystemWide"/> is true.
/// Foreign key to <see cref="ServerCluster.ClusterId"/>.
/// </summary>
public string? ClusterId { get; set; }
/// <summary>
/// <c>true</c> = grant applies across every cluster in the fleet; <c>ClusterId</c> must be null.
/// <c>false</c> = grant is cluster-scoped; <c>ClusterId</c> must be populated.
/// </summary>
public required bool IsSystemWide { get; set; }
/// <summary>Row creation timestamp (UTC).</summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>Optional human-readable note (e.g. "added 2026-04-19 for Warsaw fleet admin handoff").</summary>
public string? Notes { get; set; }
/// <summary>Navigation for EF core when the row is cluster-scoped.</summary>
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,31 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
/// namespaces are content (affect what consumers see at the endpoint), not topology.
/// </summary>
public sealed class Namespace
{
public Guid NamespaceRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
public required string NamespaceId { get; set; }
public required string ClusterId { get; set; }
public required NamespaceKind Kind { get; set; }
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
public required string NamespaceUri { get; set; }
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
/// </summary>
public sealed class NodeAcl
{
public Guid NodeAclRowId { get; set; }
public long GenerationId { get; set; }
public required string NodeAclId { get; set; }
public required string ClusterId { get; set; }
public required string LdapGroup { get; set; }
public required NodeAclScopeKind ScopeKind { get; set; }
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
public string? ScopeId { get; set; }
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
public required NodePermissions PermissionFlags { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
public sealed class PollGroup
{
public Guid PollGroupRowId { get; set; }
public long GenerationId { get; set; }
public required string PollGroupId { get; set; }
public required string DriverInstanceId { get; set; }
public required string Name { get; set; }
public int IntervalMs { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
/// per generation. <c>SourceHash</c> is the compile-cache key.
/// </summary>
/// <remarks>
/// <para>
/// Scripts are generation-scoped: a draft's edit creates a new row in the draft
/// generation, the old row stays frozen in the published generation. Shape mirrors
/// the other generation-scoped entities (Equipment, Tag, etc.) — <c>ScriptId</c> is
/// the stable logical id that carries across generations; <c>ScriptRowId</c> is the
/// row identity.
/// </para>
/// </remarks>
public sealed class Script
{
public Guid ScriptRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id. Carries across generations.</summary>
public required string ScriptId { get; set; }
/// <summary>Operator-friendly name for log filtering + Admin UI list view.</summary>
public required string Name { get; set; }
/// <summary>Raw C# source. Size bounded by the DB column (nvarchar(max)).</summary>
public required string SourceCode { get; set; }
/// <summary>SHA-256 of <see cref="SourceCode"/> — compile-cache key for Phase 7 Stream A's <c>CompiledScriptCache</c>.</summary>
public required string SourceHash { get; set; }
/// <summary>Language — always "CSharp" today; placeholder for future engines (Python/Lua).</summary>
public string Language { get; set; } = "CSharp";
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
/// condition is the predicate <see cref="Script"/> referenced by
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
/// </summary>
/// <remarks>
/// <para>
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
/// Historian alarm schema.
/// </para>
/// </remarks>
public sealed class ScriptedAlarm
{
public Guid ScriptedAlarmRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this alarm.</summary>
public required string EquipmentId { get; set; }
/// <summary>Operator-facing alarm name.</summary>
public required string Name { get; set; }
/// <summary>Concrete Part 9 type — "AlarmCondition" / "LimitAlarm" / "OffNormalAlarm" / "DiscreteAlarm".</summary>
public required string AlarmType { get; set; }
/// <summary>Numeric severity 1..1000 per OPC UA Part 9 (usual bands: 1-250 Low, 251-500 Medium, 501-750 High, 751-1000 Critical).</summary>
public int Severity { get; set; } = 500;
/// <summary>Template with <c>{TagPath}</c> tokens resolved at emission time.</summary>
public required string MessageTemplate { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — predicate script returning <c>bool</c>.</summary>
public required string PredicateScriptId { get; set; }
/// <summary>
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
/// primary motivation for the historian sink; operator can disable per alarm.
/// </summary>
public bool HistorizeToAveva { get; set; } = true;
/// <summary>
/// OPC UA Part 9 <c>Retain</c> flag — whether the alarm keeps active-state between
/// sessions. Most plant alarms are retained; one-shot event-style alarms are not.
/// </summary>
public bool Retain { get; set; } = true;
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
/// Survives process restart so operators don't re-ack and ack history survives for
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
/// per-generation) because ack state follows the alarm's stable identity across
/// generations — a Modified alarm keeps its ack history.
/// </summary>
/// <remarks>
/// <para>
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
/// </para>
/// <para>
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
/// serializes directly into this column.
/// </para>
/// </remarks>
public sealed class ScriptedAlarmState
{
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
public required string EnabledState { get; set; } = "Enabled";
/// <summary>Unacknowledged / Acknowledged.</summary>
public required string AckedState { get; set; } = "Unacknowledged";
/// <summary>Unconfirmed / Confirmed.</summary>
public required string ConfirmedState { get; set; } = "Unconfirmed";
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
public required string ShelvingState { get; set; } = "Unshelved";
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
public DateTime? ShelvingExpiresUtc { get; set; }
/// <summary>User who last acknowledged. Null if never acked.</summary>
public string? LastAckUser { get; set; }
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
public string? LastAckComment { get; set; }
public DateTime? LastAckUtc { get; set; }
/// <summary>User who last confirmed.</summary>
public string? LastConfirmUser { get; set; }
public string? LastConfirmComment { get; set; }
public DateTime? LastConfirmUtc { get; set; }
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
public string CommentsJson { get; set; } = "[]";
/// <summary>Row write timestamp — tracks last state change.</summary>
public DateTime UpdatedAtUtc { get; set; }
}

View File

@@ -0,0 +1,42 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
/// Per <c>config-db-schema.md</c> ServerCluster table.
/// </summary>
public sealed class ServerCluster
{
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
public required string ClusterId { get; set; }
public required string Name { get; set; }
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
public required string Enterprise { get; set; }
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
public required string Site { get; set; }
public byte NodeCount { get; set; }
public required RedundancyMode RedundancyMode { get; set; }
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public DateTime? ModifiedAt { get; set; }
public string? ModifiedBy { get; set; }
// Navigation
public ICollection<ClusterNode> Nodes { get; set; } = [];
public ICollection<Namespace> Namespaces { get; set; } = [];
public ICollection<ConfigGeneration> Generations { get; set; } = [];
}

View File

@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
/// </summary>
public sealed class Tag
{
public Guid TagRowId { get; set; }
public long GenerationId { get; set; }
public required string TagId { get; set; }
public required string DriverInstanceId { get; set; }
public string? DeviceId { get; set; }
/// <summary>
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
/// </summary>
public string? EquipmentId { get; set; }
public required string Name { get; set; }
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
public string? FolderPath { get; set; }
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
public required string DataType { get; set; }
public required TagAccessLevel AccessLevel { get; set; }
/// <summary>Per decisions #4445 — opt-in for write retry eligibility.</summary>
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
public required string TagConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsArea
{
public Guid UnsAreaRowId { get; set; }
public long GenerationId { get; set; }
public required string UnsAreaId { get; set; }
public required string ClusterId { get; set; }
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsLine
{
public Guid UnsLineRowId { get; set; }
public long GenerationId { get; set; }
public required string UnsLineId { get; set; }
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
public required string UnsAreaId { get; set; }
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
/// Equipment tree alongside driver tags. Value is produced by the
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
/// <c>Tag.DataType</c>.
/// </para>
/// <para>
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
/// plan decision #3 (change + timer). At least one must produce evaluations; the
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
/// </para>
/// </remarks>
public sealed class VirtualTag
{
public Guid VirtualTagRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical id.</summary>
public required string VirtualTagId { get; set; }
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
public required string EquipmentId { get; set; }
/// <summary>Browse name — unique within owning Equipment.</summary>
public required string Name { get; set; }
/// <summary>DataType string — same vocabulary as <see cref="Tag.DataType"/>.</summary>
public required string DataType { get; set; }
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — the script that computes this tag's value.</summary>
public required string ScriptId { get; set; }
/// <summary>Re-evaluate when any referenced input tag changes. Default on.</summary>
public bool ChangeTriggered { get; set; } = true;
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
public int? TimerIntervalMs { get; set; }
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}