diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor
index 57af836f..845b9e06 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Raw/GlobalRaw.razor
@@ -55,6 +55,12 @@
try
{
_roots = await Svc.LoadRootsAsync();
+ // Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is
+ // visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are
+ // eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay
+ // collapsed and load their folders/drivers on first expand.
+ foreach (var enterprise in _roots)
+ enterprise.Expanded = true;
}
catch (Exception ex)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs
index 973a989c..ad54d8c2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs
@@ -69,7 +69,7 @@ public sealed class RawNode
/// Whether a Driver/Device node is enabled (drives the enable/disable menu label + styling); true otherwise.
public bool Enabled { get; init; } = true;
- /// Direct child count (for the badge). For a device this is its tag-group + tag count.
+ /// Direct child count (for the badge) — for a device, its root tag-groups + root tags (direct children only, not the whole subtree).
public int ChildCount { get; set; }
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
index d5370f59..0965d71c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs
@@ -370,8 +370,7 @@ public sealed class RawTreeService(IDbContextFactory dbF
ParentRawFolderId = parentFolderId,
Name = name,
});
- await db.SaveChangesAsync(ct);
- return new UnsMutationResult(true, null, id);
+ return await SaveCreateAsync(db, "folder", id, ct);
}
///
@@ -510,8 +509,7 @@ public sealed class RawTreeService(IDbContextFactory dbF
DeviceConfig = "{}",
});
- await db.SaveChangesAsync(ct);
- return new UnsMutationResult(true, null, driverId);
+ return await SaveCreateAsync(db, "driver", driverId, ct);
}
///
@@ -618,8 +616,7 @@ public sealed class RawTreeService(IDbContextFactory dbF
Name = name,
DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson,
});
- await db.SaveChangesAsync(ct);
- return new UnsMutationResult(true, null, id);
+ return await SaveCreateAsync(db, "device", id, ct);
}
///
@@ -703,8 +700,7 @@ public sealed class RawTreeService(IDbContextFactory dbF
ParentTagGroupId = parentGroupId,
Name = name,
});
- await db.SaveChangesAsync(ct);
- return new UnsMutationResult(true, null, id);
+ return await SaveCreateAsync(db, "tag group", id, ct);
}
///
@@ -840,10 +836,30 @@ public sealed class RawTreeService(IDbContextFactory dbF
{
return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it.");
}
- catch (Exception ex)
+ catch (Exception)
{
// Defensive net for a real SQL Server where a child slipped past the pre-check (race).
- return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
+ // Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance.
+ return new UnsMutationResult(false,
+ $"Delete failed — a related record still references this {noun}. Reload and retry.");
+ }
+ }
+
+ /// Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure.
+ private static async Task SaveCreateAsync(
+ OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct)
+ {
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ return new UnsMutationResult(true, null, createdId);
+ }
+ catch (DbUpdateException)
+ {
+ // The name-uniqueness pre-check narrows but doesn't close the race against a concurrent
+ // create of the same-name sibling; the DB's filtered-unique index is the backstop.
+ return new UnsMutationResult(false,
+ $"A {noun} with that name already exists here (created concurrently). Reload and retry.");
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
index 4e12e47b..03297bd5 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
@@ -29,8 +29,9 @@ public class PageAuthorizationGuardTests
///
private static readonly IReadOnlyDictionary ExpectedPolicy = new Dictionary
{
- // ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ──
+ // ── ConfigEditor (21): the config-authoring surface (incl. live ResilienceConfig) ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
+ [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor,
[typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
[typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
[typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
@@ -54,8 +55,9 @@ public class PageAuthorizationGuardTests
// ── FleetAdmin (1) ──
[typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,
- // ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ──
+ // ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
+ [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
[typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,