From 8464e3f37655bc313af2ea4a379bac3d482c999b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 15:38:41 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2033=20=E2=80=94=20DriverHostSta?= =?UTF-8?q?tus=20entity=20+=20EF=20migration=20(data-layer=20for=20LMX=20#?= =?UTF-8?q?7).=20New=20DriverHostStatus=20entity=20with=20composite=20key?= =?UTF-8?q?=20(NodeId,=20DriverInstanceId,=20HostName)=20persists=20each?= =?UTF-8?q?=20server=20node's=20per-host=20connectivity=20view=20=E2=80=94?= =?UTF-8?q?=20one=20row=20per=20(server=20node,=20driver=20instance,=20pro?= =?UTF-8?q?be-reported=20host),=20which=20means=20a=20redundant=202-node?= =?UTF-8?q?=20cluster=20with=20one=20Galaxy=20driver=20reporting=203=20pla?= =?UTF-8?q?tforms=20produces=206=20rows=20because=20each=20server=20node?= =?UTF-8?q?=20owns=20its=20own=20runtime=20view=20of=20the=20shared=20host?= =?UTF-8?q?=20topology,=20not=203.=20Fields:=20NodeId=20(64),=20DriverInst?= =?UTF-8?q?anceId=20(64),=20HostName=20(256=20=E2=80=94=20fits=20Galaxy=20?= =?UTF-8?q?FQDNs=20and=20Modbus=20host:port=20strings),=20State=20(DriverH?= =?UTF-8?q?ostState=20enum=20=E2=80=94=20Unknown/Running/Stopped/Faulted,?= =?UTF-8?q?=20persisted=20as=20nvarchar(16)=20via=20HasConversion?= =?UTF-8?q?=20so=20DBAs=20inspecting=20the=20table=20see=20readable=20stat?= =?UTF-8?q?e=20names=20not=20ordinals),=20StateChangedUtc=20+=20LastSeenUt?= =?UTF-8?q?c=20(datetime2(3)=20=E2=80=94=20StateChangedUtc=20tracks=20actu?= =?UTF-8?q?al=20transitions=20while=20LastSeenUtc=20advances=20on=20every?= =?UTF-8?q?=20publisher=20heartbeat=20so=20the=20Admin=20UI=20can=20flag?= =?UTF-8?q?=20stale=20rows=20from=20a=20crashed=20Server=20independent=20o?= =?UTF-8?q?f=20State),=20Detail=20(nullable=201024=20=E2=80=94=20exception?= =?UTF-8?q?=20message=20from=20the=20driver's=20probe=20when=20Faulted,=20?= =?UTF-8?q?null=20otherwise).=20DriverHostState=20enum=20lives=20in=20Conf?= =?UTF-8?q?iguration.Enums/=20rather=20than=20reusing=20Core.Abstractions.?= =?UTF-8?q?HostState=20so=20the=20Configuration=20project=20stays=20free?= =?UTF-8?q?=20of=20driver-runtime=20dependencies=20(it's=20referenced=20by?= =?UTF-8?q?=20both=20the=20Admin=20process=20and=20the=20Server=20process,?= =?UTF-8?q?=20so=20pulling=20in=20the=20driver-abstractions=20assembly=20t?= =?UTF-8?q?o=20every=20Admin=20build=20would=20be=20unnecessary=20weight).?= =?UTF-8?q?=20The=20server-side=20publisher=20hosted=20service=20(follow-u?= =?UTF-8?q?p=20PR=2034)=20will=20translate=20HostStatusChangedEventArgs.Ne?= =?UTF-8?q?wState=20to=20this=20enum=20on=20every=20transition.=20No=20for?= =?UTF-8?q?eign=20key=20to=20ClusterNode=20=E2=80=94=20a=20Server=20may=20?= =?UTF-8?q?start=20reporting=20host=20status=20before=20its=20ClusterNode?= =?UTF-8?q?=20row=20exists=20(first-boot=20bootstrap),=20and=20we'd=20rath?= =?UTF-8?q?er=20keep=20the=20status=20row=20than=20drop=20it.=20The=20Admi?= =?UTF-8?q?n-side=20service=20that=20renders=20the=20dashboard=20will=20le?= =?UTF-8?q?ft-join=20on=20NodeId=20when=20presenting.=20Two=20indexes=20de?= =?UTF-8?q?clared:=20IX=5FDriverHostStatus=5FNode=20drives=20the=20per-clu?= =?UTF-8?q?ster=20drill-down=20(Admin=20UI=20joins=20ClusterNode=20on=20Cl?= =?UTF-8?q?usterId=20to=20pick=20which=20NodeIds=20to=20fetch),=20IX=5FDri?= =?UTF-8?q?verHostStatus=5FLastSeen=20drives=20the=20stale-row=20query=20(?= =?UTF-8?q?now=20-=20LastSeen=20>=20threshold).=20EF=20migration=20AddDriv?= =?UTF-8?q?erHostStatus=20creates=20the=20table=20+=20PK=20+=20both=20inde?= =?UTF-8?q?xes.=20Model=20snapshot=20updated.=20SchemaComplianceTests=20ex?= =?UTF-8?q?pected-tables=20list=20extended.=20DriverHostStatusTests=20(3?= =?UTF-8?q?=20new=20cases,=20category=20SchemaCompliance,=20uses=20the=20s?= =?UTF-8?q?hared=20fixture=20DB):=20composite=20key=20allows=20same=20(hos?= =?UTF-8?q?t,=20driver)=20across=20different=20nodes=20AND=20same=20(node,?= =?UTF-8?q?=20host)=20across=20different=20drivers=20=E2=80=94=20both=20re?= =?UTF-8?q?al-world=20cases=20the=20publisher=20needs=20to=20support;=20up?= =?UTF-8?q?sert-in-place=20pattern=20(fetch-by-composite-PK,=20mutate,=20s?= =?UTF-8?q?ave)=20produces=20one=20row=20not=20two=20=E2=80=94=20the=20pat?= =?UTF-8?q?tern=20the=20publisher=20will=20use;=20State=20enum=20persists?= =?UTF-8?q?=20as=20string=20not=20int=20=E2=80=94=20reading=20the=20DB=20v?= =?UTF-8?q?ia=20ADO.NET=20returns=20'Faulted'=20not=20'3'.=20Configuration?= =?UTF-8?q?.Tests=20SchemaCompliance=20suite:=2010=20pass=20/=200=20fail?= =?UTF-8?q?=20(7=20prior=20+=203=20new).=20Configuration=20build=20clean.?= =?UTF-8?q?=20No=20Server=20or=20Admin=20code=20changes=20yet=20=E2=80=94?= =?UTF-8?q?=20publisher=20+=20/hosts=20page=20are=20PR=2034.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Entities/DriverHostStatus.cs | 61 + .../Enums/DriverHostState.cs | 21 + ...0418193608_AddDriverHostStatus.Designer.cs | 1248 +++++++++++++++++ .../20260418193608_AddDriverHostStatus.cs | 49 + .../OtOpcUaConfigDbContextModelSnapshot.cs | 40 + .../OtOpcUaConfigDbContext.cs | 28 + .../DriverHostStatusTests.cs | 128 ++ .../SchemaComplianceTests.cs | 1 + 8 files changed, 1576 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs new file mode 100644 index 0000000..440d1ce --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverHostStatus.cs @@ -0,0 +1,61 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Per-host connectivity snapshot the Server publishes for each driver's +/// IHostConnectivityProbe.GetHostStatuses entry. One row per +/// (, , ) 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. +/// +/// +/// +/// 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 OnHostStatusChanged and upserts rows on transitions + +/// periodic liveness heartbeats. advances on every +/// heartbeat so the Admin UI can flag stale rows from a crashed Server. +/// +/// +/// No foreign-key to — 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. +/// +/// +public sealed class DriverHostStatus +{ + /// Server node that's running the driver. + public required string NodeId { get; set; } + + /// Driver instance's stable id (matches IDriver.DriverInstanceId). + public required string DriverInstanceId { get; set; } + + /// + /// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus + /// host:port, whatever the probe returns. Opaque to the Admin UI except as + /// a display string. + /// + public required string HostName { get; set; } + + public DriverHostState State { get; set; } = DriverHostState.Unknown; + + /// Timestamp of the last state transition (not of the most recent heartbeat). + public DateTime StateChangedUtc { get; set; } + + /// + /// Advances on every publisher heartbeat — the Admin UI uses + /// now - LastSeenUtc > threshold to flag rows whose owning Server has + /// stopped reporting (crashed, network-partitioned, etc.), independent of + /// . + /// + public DateTime LastSeenUtc { get; set; } + + /// + /// Optional human-readable detail populated when is + /// — e.g. the exception message from the + /// driver's probe. Null for Running / Stopped / Unknown transitions. + /// + public string? Detail { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs new file mode 100644 index 0000000..8ef0c2b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// Persisted mirror of Core.Abstractions.HostState — the lifecycle state each +/// IHostConnectivityProbe-capable driver reports for its per-host topology +/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams). +/// Defined here instead of re-using Core.Abstractions.HostState so the +/// Configuration project stays free of driver-runtime dependencies. +/// +/// +/// The server-side publisher (follow-up PR) translates +/// HostStatusChangedEventArgs.NewState to this enum on every transition and +/// upserts into . Admin UI reads from the DB. +/// +public enum DriverHostState +{ + Unknown, + Running, + Stopped, + Faulted, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs new file mode 100644 index 0000000..e6111be --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs @@ -0,0 +1,1248 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260418193608_AddDriverHostStatus")] + partial class AddDriverHostStatus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.cs new file mode 100644 index 0000000..ab14c5d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddDriverHostStatus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DriverHostStatus", + columns: table => new + { + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + HostName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + State = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + StateChangedUtc = table.Column(type: "datetime2(3)", nullable: false), + LastSeenUtc = table.Column(type: "datetime2(3)", nullable: false), + Detail = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName }); + }); + + migrationBuilder.CreateIndex( + name: "IX_DriverHostStatus_LastSeen", + table: "DriverHostStatus", + column: "LastSeenUtc"); + + migrationBuilder.CreateIndex( + name: "IX_DriverHostStatus_Node", + table: "DriverHostStatus", + column: "NodeId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DriverHostStatus"); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index d7912e1..9b5b8f1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations }); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => { b.Property("DriverInstanceRowId") diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index ecec6d6..914b3dd 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions ClusterNodeGenerationStates => Set(); public DbSet ConfigAuditLogs => Set(); public DbSet ExternalIdReservations => Set(); + public DbSet DriverHostStatuses => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment"); }); } + + private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("DriverHostStatus"); + // Composite key — one row per (server node, driver instance, probe-reported host). + // A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces + // 6 rows because each server node owns its own runtime view; the composite key is + // what lets both views coexist without shadowing each other. + e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName }); + e.Property(x => x.NodeId).HasMaxLength(64); + e.Property(x => x.DriverInstanceId).HasMaxLength(64); + e.Property(x => x.HostName).HasMaxLength(256); + e.Property(x => x.State).HasConversion().HasMaxLength(16); + e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)"); + e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)"); + e.Property(x => x.Detail).HasMaxLength(1024); + + // NodeId-only index drives the Admin UI's per-cluster drill-down (select all host + // statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId). + e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node"); + // LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N). + e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen"); + }); + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs new file mode 100644 index 0000000..806f80a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DriverHostStatusTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// End-to-end round-trip through the DB for the entity +/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId, +/// HostName), string-backed DriverHostState conversion, and the two indexes the +/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc). +/// +[Trait("Category", "SchemaCompliance")] +[Collection(nameof(SchemaComplianceCollection))] +public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture) +{ + [Fact] + public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers() + { + await using var ctx = NewContext(); + + // Same HostName + DriverInstanceId across two different server nodes — classic 2-node + // redundancy case. Both rows must be insertable because each server node owns its own + // runtime view of the shared host. + var now = DateTime.UtcNow; + ctx.DriverHostStatuses.Add(new DriverHostStatus + { + NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", + State = DriverHostState.Running, + StateChangedUtc = now, LastSeenUtc = now, + }); + ctx.DriverHostStatuses.Add(new DriverHostStatus + { + NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform", + State = DriverHostState.Stopped, + StateChangedUtc = now, LastSeenUtc = now, + Detail = "secondary hasn't taken over yet", + }); + // Same server node + host, different driver instance — second driver doesn't clobber. + ctx.DriverHostStatuses.Add(new DriverHostStatus + { + NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform", + State = DriverHostState.Running, + StateChangedUtc = now, LastSeenUtc = now, + }); + await ctx.SaveChangesAsync(); + + var rows = await ctx.DriverHostStatuses.AsNoTracking() + .Where(r => r.HostName == "GRPlatform").ToListAsync(); + + rows.Count.ShouldBe(3); + rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1"); + rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet"); + rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1"); + } + + [Fact] + public async Task Upsert_pattern_for_same_key_updates_in_place() + { + // The publisher hosted service (follow-up PR) upserts on every transition + + // heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update + // keyed on the composite PK. If the composite key ever changes, this test breaks + // loudly so the publisher gets a synchronized update. + await using var ctx = NewContext(); + var t0 = DateTime.UtcNow; + ctx.DriverHostStatuses.Add(new DriverHostStatus + { + NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host", + State = DriverHostState.Running, + StateChangedUtc = t0, LastSeenUtc = t0, + }); + await ctx.SaveChangesAsync(); + + var t1 = t0.AddSeconds(30); + await using (var ctx2 = NewContext()) + { + var existing = await ctx2.DriverHostStatuses.SingleAsync(r => + r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host"); + existing.State = DriverHostState.Faulted; + existing.StateChangedUtc = t1; + existing.LastSeenUtc = t1; + existing.Detail = "transport reset by peer"; + await ctx2.SaveChangesAsync(); + } + + await using var ctx3 = NewContext(); + var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r => + r.NodeId == "upsert-node" && r.HostName == "upsert-host"); + final.State.ShouldBe(DriverHostState.Faulted); + final.Detail.ShouldBe("transport reset by peer"); + // Only one row — a naive "always insert" would have created a duplicate PK and thrown. + (await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1); + } + + [Fact] + public async Task Enum_persists_as_string_not_int() + { + // Fluent config sets HasConversion() on State — the DB stores 'Running' / + // 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw + // string back via ADO; if someone drops the conversion the column will contain '1' + // / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table + // directly should see readable state names, not enum ordinals. + await using var ctx = NewContext(); + ctx.DriverHostStatuses.Add(new DriverHostStatus + { + NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host", + State = DriverHostState.Faulted, + StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + + await using var conn = fixture.OpenConnection(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'"; + var rawValue = (string?)await cmd.ExecuteScalarAsync(); + rawValue.ShouldBe("Faulted"); + } + + private OtOpcUaConfigDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(fixture.ConnectionString) + .Options; + return new OtOpcUaConfigDbContext(options); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index 2a792d4..68da688 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests "Namespace", "UnsArea", "UnsLine", "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "NodeAcl", "ExternalIdReservation", + "DriverHostStatus", }; var actual = QueryStrings(@" -- 2.49.1