diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
index b3ae1207..76e03263 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor
@@ -80,6 +80,10 @@
Delete @_confirmNode.DisplayName? This cannot be undone.
+ @if (_confirmNode.Kind == UnsNodeKind.Enterprise)
+ {
+
This will delete every cluster under this enterprise.
+ }
@if (!string.IsNullOrWhiteSpace(_confirmError))
{
@_confirmError
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs
index 2f6628a4..98e8f964 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagHistorizeConfigTests.cs
@@ -123,4 +123,24 @@ public sealed class TagHistorizeConfigTests
json.ShouldStartWith("{");
json.ShouldEndWith("}");
}
+
+ ///
+ /// Full seam: the history helper and a driver-typed editor compose over the same canonical TagConfig
+ /// blob without clobbering each other. Setting the history keys, round-tripping through the
+ /// OpcUaClient typed editor (FromJson → ToJson), then reading the history keys back recovers them
+ /// intact — and the editor's own FullName field survives the history merge.
+ ///
+ [Fact]
+ public void History_keys_survive_a_typed_editor_round_trip()
+ {
+ var withHistory = TagHistorizeConfig.Set(
+ """{"FullName":"ns=2;s=X"}""", isHistorized: true, historianTagname: "TN");
+
+ var afterEditor = OpcUaClientTagConfigModel.FromJson(withHistory).ToJson();
+
+ var h = TagHistorizeConfig.Read(afterEditor);
+ h.IsHistorized.ShouldBeTrue();
+ h.HistorianTagname.ShouldBe("TN");
+ OpcUaClientTagConfigModel.FromJson(afterEditor).FullName.ShouldBe("ns=2;s=X");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs
index 304d4af4..4af90ffd 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs
@@ -122,6 +122,63 @@ public sealed class UnsTreeServiceDeleteClusterTests
verify.ServerClusters.Any(c => c.ClusterId == "CL-DRV").ShouldBeTrue();
}
+ ///
A cluster with a cluster node refuses deletion with a friendly message and stays put.
+ [Fact]
+ public async Task DeleteCluster_with_cluster_node_refuses_and_keeps_row()
+ {
+ var (service, dbName) = Fresh();
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ db.ServerClusters.Add(NewCluster("CL-NODE", "zb"));
+ db.ClusterNodes.Add(new ClusterNode
+ {
+ NodeId = "NODE-CL",
+ ClusterId = "CL-NODE",
+ Host = "host-a",
+ ApplicationUri = "urn:zb:cl-node:a",
+ CreatedBy = "test",
+ });
+ db.SaveChanges();
+ }
+
+ var result = await service.DeleteClusterAsync("CL-NODE");
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ result.Error.ShouldContain("CL-NODE");
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ verify.ServerClusters.Any(c => c.ClusterId == "CL-NODE").ShouldBeTrue();
+ }
+
+ ///
A cluster with a namespace refuses deletion with a friendly message and stays put.
+ [Fact]
+ public async Task DeleteCluster_with_namespace_refuses_and_keeps_row()
+ {
+ var (service, dbName) = Fresh();
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ db.ServerClusters.Add(NewCluster("CL-NS", "zb"));
+ db.Namespaces.Add(new Namespace
+ {
+ NamespaceId = "NS-CL",
+ ClusterId = "CL-NS",
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:zb:cl-ns:equipment",
+ });
+ db.SaveChanges();
+ }
+
+ var result = await service.DeleteClusterAsync("CL-NS");
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldNotBeNull();
+ result.Error.ShouldContain("CL-NS");
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ verify.ServerClusters.Any(c => c.ClusterId == "CL-NS").ShouldBeTrue();
+ }
+
// ----- DeleteEnterprise -----
///
Deleting an enterprise whose every cluster is child-free removes them all.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
index 80c0c4d7..e288aec3 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs
@@ -345,6 +345,85 @@ public sealed class DeploymentArtifactAliasParityTests
composed.EquipmentTags.Single(t => t.TagId == "tag-modbus").Writable.ShouldBeTrue();
}
+ ///
+ /// The native-alarm historizeToAveva opt-out (bool?, the per-condition durable-AVEVA-write
+ /// gate) is parsed by BOTH equipment-tag producers' ExtractTagAlarm and MUST stay byte-parity:
+ /// for the SAME tag TagConfig carrying alarm.historizeToAveva: true (and : false), the
+ /// live-edit composer () and the artifact decoder
+ /// () must derive the
+ /// identical EquipmentTagAlarmInfo.HistorizeToAveva. The galaxy-tag parity test above already
+ /// covers the absent ⇒ null case; this pins the explicit-bool branch on both sides.
+ ///
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Composer_and_artifact_agree_on_alarm_historizeToAveva(bool historizeToAveva)
+ {
+ var ns = new Namespace
+ {
+ NamespaceId = "ns-eq",
+ ClusterId = "c1",
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:eq",
+ };
+ var driver = new DriverInstance
+ {
+ DriverInstanceId = "drv-modbus",
+ ClusterId = "c1",
+ NamespaceId = "ns-eq",
+ Name = "Modbus1",
+ DriverType = "Modbus",
+ DriverConfig = "{}",
+ };
+ var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
+ var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
+ var equip = new Equipment
+ {
+ EquipmentId = "eq-1",
+ DriverInstanceId = "drv-modbus",
+ UnsLineId = "line-1",
+ Name = "FillingPump",
+ MachineCode = "FILLINGPUMP",
+ };
+ var alarmJson = historizeToAveva ? "true" : "false";
+ var alarmTag = new Tag
+ {
+ TagId = "tag-alarm",
+ DriverInstanceId = "drv-modbus",
+ EquipmentId = "eq-1",
+ FolderPath = null,
+ Name = "Temp",
+ DataType = "Float",
+ AccessLevel = TagAccessLevel.Read,
+ TagConfig = $"{{\"FullName\":\"40001\",\"alarm\":{{\"alarmType\":\"OffNormalAlarm\",\"severity\":700,\"historizeToAveva\":{alarmJson}}}}}",
+ };
+
+ // ---- Side 1: the live-edit composer ----
+ var composed = Phase7Composer.Compose(
+ new[] { area }, new[] { line }, new[] { equip }, new[] { driver },
+ Array.Empty
(), new[] { alarmTag }, new[] { ns });
+
+ // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ----
+ var blob = JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ Namespaces = new[] { new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind } },
+ DriverInstances = new[]
+ {
+ new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId },
+ },
+ Tags = new[] { ToSnapshot(alarmTag) },
+ });
+
+ var decoded = DeploymentArtifact.ParseComposition(blob);
+
+ // ---- Byte-parity: HistorizeToAveva matches between producers and equals the seeded bool. ----
+ var decodedAlarm = decoded.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull();
+ var composedAlarm = composed.EquipmentTags.ShouldHaveSingleItem().Alarm.ShouldNotBeNull();
+ decodedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva);
+ composedAlarm.HistorizeToAveva.ShouldBe(historizeToAveva);
+ decodedAlarm.ShouldBe(composedAlarm); // EquipmentTagAlarmInfo is a positional record ⇒ value equality
+ }
+
/// The full Pascal-case snapshot a EF entity serialises to in the
/// artifact (matches ConfigComposer): the equipment-tag decoder reads exactly these fields.
private static object ToSnapshot(Tag t) => new