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(@"