Compare commits
2 Commits
f19f2ee73b
...
9169386eca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9169386eca | |||
| b87d877270 |
@@ -154,3 +154,5 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc
|
||||
```
|
||||
|
||||
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
|
||||
|
||||
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
|
||||
|
||||
@@ -59,6 +59,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
|-----|--------|
|
||||
| [Configuration.md](Configuration.md) | Live appsettings + environment-variable reference (current state) |
|
||||
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
|
||||
| [Uns.md](Uns.md) | The global `/uns` master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# UNS — Global Unified-Namespace Management
|
||||
|
||||
The **UNS** page (`/uns` in the AdminUI) is the single surface for managing
|
||||
the Unified Namespace across the whole fleet. It replaces the old
|
||||
per-cluster **UNS**, **Equipment**, and **Tags** tabs and the standalone
|
||||
virtual-tags list — those have been removed; everything now lives in one
|
||||
global master tree.
|
||||
|
||||
## The tree
|
||||
|
||||
The page shows every layer of the UNS as one expandable tree:
|
||||
|
||||
```
|
||||
Enterprise (read-only grouping — ServerCluster.Enterprise)
|
||||
└─ Site / Cluster (read-only grouping — a ServerCluster row)
|
||||
└─ Area (editable — UnsArea)
|
||||
└─ Line (editable — UnsLine)
|
||||
└─ Equipment (editable — Equipment)
|
||||
├─ Tag (editable — equipment-bound Tag)
|
||||
└─ Virtual tag (editable — VirtualTag)
|
||||
```
|
||||
|
||||
**Enterprise and Site/Cluster are read-only here.** They are derived from
|
||||
columns on the cluster record, not entities of their own, so you create and
|
||||
configure clusters on the **Clusters** pages (`/clusters`). On a cluster row
|
||||
the **⚙ settings** link jumps to that cluster. Editable UNS entities start
|
||||
at **Area**.
|
||||
|
||||
Count badges next to a node show how many direct children it has (for
|
||||
equipment, the combined tag + virtual-tag count).
|
||||
|
||||
### Navigating
|
||||
|
||||
- **Expand all / Collapse all** toggle the structural levels. Equipment
|
||||
nodes are left collapsed by Expand-all because their tags/virtual-tags are
|
||||
**lazy-loaded** — they fetch on first expand (you'll see a brief spinner).
|
||||
- **Filter by name** does a case-insensitive substring match on the names of
|
||||
a node's direct children.
|
||||
|
||||
## Creating and editing
|
||||
|
||||
Every editable row has inline actions; clicking one opens a modal:
|
||||
|
||||
| Node | Actions |
|
||||
|---|---|
|
||||
| Cluster | **+ Area** |
|
||||
| Area | **+ Line**, Edit, Delete |
|
||||
| Line | **+ Equipment**, Edit, Delete |
|
||||
| Equipment | **+ Tag**, **+ Virtual tag**, Edit, Delete |
|
||||
| Tag / Virtual tag | Edit, Delete |
|
||||
|
||||
A **+ Child** action pre-fills the parent for you (e.g. **+ Line** on an
|
||||
area opens the Line modal with that area already selected). Build a branch
|
||||
top-down: Area → Line → Equipment → Tag / Virtual tag.
|
||||
|
||||
### Served-by cluster
|
||||
|
||||
An area's **cluster assignment is its "served-by" cluster** — the cluster
|
||||
node that runs it. It's set when you create the area (under a cluster) and
|
||||
changed by editing the area's cluster in the Area modal, which moves the
|
||||
whole branch. There is no separate "served-by" concept and no migration —
|
||||
it is simply `UnsArea.ClusterId`.
|
||||
|
||||
### Tags vs. Galaxy / SystemPlatform tags
|
||||
|
||||
Tags created here are **equipment-bound** and require a driver instance.
|
||||
The driver list in the Tag modal is scoped to the equipment's cluster and to
|
||||
drivers on an **Equipment-kind** namespace, so a driver-less equipment shows
|
||||
no eligible drivers until you bind one (edit the equipment and pick a driver).
|
||||
|
||||
**Galaxy / AVEVA System Platform tags are not shown in this tree.** They hang
|
||||
off the driver's folder path and are auto-materialised from the Galaxy
|
||||
browse rather than being equipment-bound, so they stay on the **Drivers** tab
|
||||
of their cluster (`/clusters/{id}/drivers`), where the address picker browses
|
||||
the live Galaxy hierarchy.
|
||||
|
||||
### Virtual tags
|
||||
|
||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||
Pick its script in the Virtual tag modal; the data type is chosen from the
|
||||
standard OPC UA type list.
|
||||
|
||||
## Bulk import
|
||||
|
||||
**Import equipment CSV** (toolbar) bulk-creates equipment across many lines
|
||||
and clusters in one pass. After an import the whole tree reloads.
|
||||
|
||||
## Applying changes
|
||||
|
||||
Edits here change the configuration only. As the page header notes,
|
||||
**changes apply on the next deployment** — run a **Deploy** (Deployments
|
||||
page) to push them into the running address space.
|
||||
|
||||
## See also
|
||||
|
||||
- [Configuration.md](Configuration.md) — the underlying config entities.
|
||||
- [VirtualTags.md](VirtualTags.md) — the scripting/virtual-tag engine.
|
||||
- Design + decision log: [plans/2026-06-08-global-uns-management-design.md](plans/2026-06-08-global-uns-management-design.md).
|
||||
@@ -127,6 +127,10 @@
|
||||
private string? _filter;
|
||||
private bool _loading = true;
|
||||
|
||||
// Guards the async modal openers (HandleAddChild/HandleAddVirtualTag/HandleEdit) so a rapid
|
||||
// double-action can't race two service loads into the same modal state.
|
||||
private bool _modalBusy;
|
||||
|
||||
// --- Area modal state ---
|
||||
private bool _areaModalVisible;
|
||||
private bool _areaModalIsNew;
|
||||
@@ -250,54 +254,72 @@
|
||||
/// </summary>
|
||||
private async Task HandleAddChild(UnsNode node)
|
||||
{
|
||||
CloseModals();
|
||||
switch (node.Kind)
|
||||
if (_modalBusy) { return; }
|
||||
_modalBusy = true;
|
||||
try
|
||||
{
|
||||
case UnsNodeKind.Cluster:
|
||||
_areaModalIsNew = true;
|
||||
_areaModalExisting = null;
|
||||
_areaModalClusterId = node.ClusterId ?? node.EntityId;
|
||||
_areaModalVisible = true;
|
||||
break;
|
||||
CloseModals();
|
||||
switch (node.Kind)
|
||||
{
|
||||
case UnsNodeKind.Cluster:
|
||||
_areaModalIsNew = true;
|
||||
_areaModalExisting = null;
|
||||
_areaModalClusterId = node.ClusterId ?? node.EntityId;
|
||||
_areaModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Area:
|
||||
_lineModalIsNew = true;
|
||||
_lineModalExisting = null;
|
||||
_lineModalAreaId = node.EntityId;
|
||||
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
||||
_lineModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Area:
|
||||
_lineModalIsNew = true;
|
||||
_lineModalExisting = null;
|
||||
_lineModalAreaId = node.EntityId;
|
||||
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
||||
_lineModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Line:
|
||||
_equipmentModalIsNew = true;
|
||||
_equipmentModalExisting = null;
|
||||
_equipmentModalLineId = node.EntityId;
|
||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||
_equipmentModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Line:
|
||||
_equipmentModalIsNew = true;
|
||||
_equipmentModalExisting = null;
|
||||
_equipmentModalLineId = node.EntityId;
|
||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||
_equipmentModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Equipment:
|
||||
_tagModalIsNew = true;
|
||||
_tagModalExisting = null;
|
||||
_tagModalEquipmentId = node.EntityId;
|
||||
_childRefreshEquipmentId = node.EntityId;
|
||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
|
||||
_tagModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Equipment:
|
||||
_tagModalIsNew = true;
|
||||
_tagModalExisting = null;
|
||||
_tagModalEquipmentId = node.EntityId;
|
||||
_childRefreshEquipmentId = node.EntityId;
|
||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(node.EntityId!);
|
||||
_tagModalVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_modalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens the create modal for a new virtual tag scoped to the clicked equipment.</summary>
|
||||
private async Task HandleAddVirtualTag(UnsNode node)
|
||||
{
|
||||
CloseModals();
|
||||
_vtagModalIsNew = true;
|
||||
_vtagModalExisting = null;
|
||||
_vtagModalEquipmentId = node.EntityId;
|
||||
_childRefreshEquipmentId = node.EntityId;
|
||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
||||
_vtagModalVisible = true;
|
||||
if (_modalBusy) { return; }
|
||||
_modalBusy = true;
|
||||
try
|
||||
{
|
||||
CloseModals();
|
||||
_vtagModalIsNew = true;
|
||||
_vtagModalExisting = null;
|
||||
_vtagModalEquipmentId = node.EntityId;
|
||||
_childRefreshEquipmentId = node.EntityId;
|
||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
||||
_vtagModalVisible = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_modalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -307,60 +329,69 @@
|
||||
/// </summary>
|
||||
private async Task HandleEdit(UnsNode node)
|
||||
{
|
||||
CloseModals();
|
||||
switch (node.Kind)
|
||||
if (_modalBusy) { return; }
|
||||
_modalBusy = true;
|
||||
try
|
||||
{
|
||||
case UnsNodeKind.Area:
|
||||
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
||||
if (area is null) { return; }
|
||||
_areaModalIsNew = false;
|
||||
_areaModalExisting = area;
|
||||
_areaModalClusterId = area.ClusterId;
|
||||
_areaModalVisible = true;
|
||||
break;
|
||||
CloseModals();
|
||||
switch (node.Kind)
|
||||
{
|
||||
case UnsNodeKind.Area:
|
||||
var area = await Svc.LoadAreaAsync(node.EntityId!);
|
||||
if (area is null) { return; }
|
||||
_areaModalIsNew = false;
|
||||
_areaModalExisting = area;
|
||||
_areaModalClusterId = area.ClusterId;
|
||||
_areaModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Line:
|
||||
var line = await Svc.LoadLineAsync(node.EntityId!);
|
||||
if (line is null) { return; }
|
||||
_lineModalIsNew = false;
|
||||
_lineModalExisting = line;
|
||||
_lineModalAreaId = line.UnsAreaId;
|
||||
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
||||
_lineModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Line:
|
||||
var line = await Svc.LoadLineAsync(node.EntityId!);
|
||||
if (line is null) { return; }
|
||||
_lineModalIsNew = false;
|
||||
_lineModalExisting = line;
|
||||
_lineModalAreaId = line.UnsAreaId;
|
||||
_lineModalAreaOptions = AreaOptionsForCluster(node.ClusterId);
|
||||
_lineModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Equipment:
|
||||
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
|
||||
if (equipment is null) { return; }
|
||||
_equipmentModalIsNew = false;
|
||||
_equipmentModalExisting = equipment;
|
||||
_equipmentModalLineId = equipment.UnsLineId;
|
||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||
_equipmentModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Equipment:
|
||||
var equipment = await Svc.LoadEquipmentAsync(node.EntityId!);
|
||||
if (equipment is null) { return; }
|
||||
_equipmentModalIsNew = false;
|
||||
_equipmentModalExisting = equipment;
|
||||
_equipmentModalLineId = equipment.UnsLineId;
|
||||
_equipmentModalLineOptions = LinesForCluster(node.ClusterId);
|
||||
_equipmentModalDriverOptions = await Svc.LoadDriversForClusterAsync(node.ClusterId!);
|
||||
_equipmentModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Tag:
|
||||
var tag = await Svc.LoadTagAsync(node.EntityId!);
|
||||
if (tag is null) { return; }
|
||||
_tagModalIsNew = false;
|
||||
_tagModalExisting = tag;
|
||||
_tagModalEquipmentId = tag.EquipmentId;
|
||||
_childRefreshEquipmentId = tag.EquipmentId;
|
||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
|
||||
_tagModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.Tag:
|
||||
var tag = await Svc.LoadTagAsync(node.EntityId!);
|
||||
if (tag is null) { return; }
|
||||
_tagModalIsNew = false;
|
||||
_tagModalExisting = tag;
|
||||
_tagModalEquipmentId = tag.EquipmentId;
|
||||
_childRefreshEquipmentId = tag.EquipmentId;
|
||||
_tagModalDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(tag.EquipmentId);
|
||||
_tagModalVisible = true;
|
||||
break;
|
||||
|
||||
case UnsNodeKind.VirtualTag:
|
||||
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
|
||||
if (vtag is null) { return; }
|
||||
_vtagModalIsNew = false;
|
||||
_vtagModalExisting = vtag;
|
||||
_vtagModalEquipmentId = vtag.EquipmentId;
|
||||
_childRefreshEquipmentId = vtag.EquipmentId;
|
||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
||||
_vtagModalVisible = true;
|
||||
break;
|
||||
case UnsNodeKind.VirtualTag:
|
||||
var vtag = await Svc.LoadVirtualTagAsync(node.EntityId!);
|
||||
if (vtag is null) { return; }
|
||||
_vtagModalIsNew = false;
|
||||
_vtagModalExisting = vtag;
|
||||
_vtagModalEquipmentId = vtag.EquipmentId;
|
||||
_childRefreshEquipmentId = vtag.EquipmentId;
|
||||
_vtagModalScriptOptions = await Svc.LoadScriptsAsync();
|
||||
_vtagModalVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_modalBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,12 +536,18 @@
|
||||
/// <summary>
|
||||
/// Reloads a single equipment node's tag/virtual-tag children in place, leaving the rest of the tree
|
||||
/// (and the user's expansion) untouched. Falls back to a full structural reload only if the node
|
||||
/// can no longer be found in the current tree.
|
||||
/// can no longer be found in the current tree. Either branch only mutates state — the caller is
|
||||
/// responsible for calling StateHasChanged() afterwards (every current caller does).
|
||||
/// </summary>
|
||||
private async Task RefreshEquipmentChildrenAsync(string equipmentId)
|
||||
{
|
||||
var node = FindEquipmentNode(equipmentId);
|
||||
if (node is null) { _roots = await Svc.LoadStructureAsync(); return; }
|
||||
if (node is null)
|
||||
{
|
||||
// Fallback: the equipment node is no longer in the current tree — reload the whole structure.
|
||||
_roots = await Svc.LoadStructureAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var kids = await Svc.LoadEquipmentChildrenAsync(equipmentId);
|
||||
node.Children.Clear();
|
||||
@@ -558,21 +595,29 @@
|
||||
private void CloseModals()
|
||||
{
|
||||
_areaModalVisible = false;
|
||||
_areaModalIsNew = false;
|
||||
_areaModalClusterId = null;
|
||||
_areaModalExisting = null;
|
||||
_lineModalVisible = false;
|
||||
_lineModalIsNew = false;
|
||||
_lineModalAreaId = null;
|
||||
_lineModalExisting = null;
|
||||
_lineModalAreaOptions = Array.Empty<(string, string)>();
|
||||
_equipmentModalVisible = false;
|
||||
_equipmentModalIsNew = false;
|
||||
_equipmentModalLineId = null;
|
||||
_equipmentModalExisting = null;
|
||||
_equipmentModalLineOptions = Array.Empty<(string, string)>();
|
||||
_equipmentModalDriverOptions = Array.Empty<(string, string)>();
|
||||
_tagModalVisible = false;
|
||||
_tagModalExisting = null;
|
||||
_tagModalIsNew = false;
|
||||
_tagModalEquipmentId = null;
|
||||
_tagModalExisting = null;
|
||||
_tagModalDriverOptions = Array.Empty<(string, string)>();
|
||||
_vtagModalVisible = false;
|
||||
_vtagModalExisting = null;
|
||||
_vtagModalIsNew = false;
|
||||
_vtagModalEquipmentId = null;
|
||||
_vtagModalExisting = null;
|
||||
_vtagModalScriptOptions = Array.Empty<(string, string)>();
|
||||
_importModalVisible = false;
|
||||
_childRefreshEquipmentId = null;
|
||||
|
||||
@@ -38,8 +38,12 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="vtag-dtype">DataType</label>
|
||||
<InputText id="vtag-dtype" @bind-Value="_form.DataType" class="form-control form-control-sm mono"
|
||||
placeholder="Double" />
|
||||
<InputSelect id="vtag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="vtag-script">Script</label>
|
||||
@@ -101,6 +105,11 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The OPC UA data types offered for a virtual tag — the same set the TagModal uses.</summary>
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
|
||||
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user