feat(uns): remove per-cluster UNS/Equipment/Tags + standalone virtual-tag pages

Deletes the 10 Razor pages superseded by the global /uns tree (Tasks 12–16):
ClusterUns, UnsAreaEdit, UnsLineEdit, ClusterEquipment, EquipmentEdit,
ImportEquipment, ClusterTags, TagEdit, VirtualTags, VirtualTagEdit.
No dangling references found; build is clean.
This commit is contained in:
Joseph Doherty
2026-06-08 14:02:32 -04:00
parent 983d30cb15
commit 1bb7482c3a
10 changed files with 0 additions and 1870 deletions
@@ -1,99 +0,0 @@
@page "/clusters/{ClusterId}/equipment"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Equipment &middot; <span class="mono">@ClusterId</span></h4>
<div class="d-flex gap-2">
<a href="/clusters/@ClusterId/equipment/import" class="btn btn-outline-primary btn-sm">Import CSV…</a>
<a href="/clusters/@ClusterId/equipment/new" class="btn btn-primary btn-sm">New equipment</a>
</div>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Equipment rows are scoped to a UNS line and optionally bound to a driver instance
(driver-less = VirtualTag-only). EquipmentId is
system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag
(ERP).
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count equipment row@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No equipment defined for this cluster.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>EquipmentId</th>
<th>Name</th>
<th>MachineCode</th>
<th>ZTag</th>
<th>Driver</th>
<th>UNS line</th>
<th>Identification</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var e in _rows)
{
<tr>
<td><span class="mono small">@e.EquipmentId</span></td>
<td>@e.Name</td>
<td><span class="mono">@e.MachineCode</span></td>
<td>@(e.ZTag ?? "—")</td>
<td><span class="mono small">@e.DriverInstanceId</span></td>
<td><span class="mono small">@e.UnsLineId</span></td>
<td class="text-muted small">
@if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { <span>@e.Manufacturer</span> }
@if (!string.IsNullOrWhiteSpace(e.Model)) { <span class="ms-1">/ @e.Model</span> }
</td>
<td><a href="/clusters/@ClusterId/equipment/@e.EquipmentId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<Equipment>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId);
// Driver-less equipment (DriverInstanceId == null) has no DriverInstance FK.
// Scope it to this cluster via UnsLine → UnsArea.ClusterId instead.
var areaIds = db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId);
var linesInCluster = db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId)).Select(l => l.UnsLineId);
_rows = await db.Equipment.AsNoTracking()
.Where(e => driversInCluster.Contains(e.DriverInstanceId)
|| (e.DriverInstanceId == null && linesInCluster.Contains(e.UnsLineId)))
.OrderBy(e => e.Name)
.ToListAsync();
}
}
@@ -1,106 +0,0 @@
@page "/clusters/{ClusterId}/tags"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Tags &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/tags/new" class="btn btn-primary btn-sm">New tag</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Tags are bound to a driver instance and (optionally) an equipment + poll group. The view
below shows the first @PageSize tags by Name.
</section>
<div class="d-flex align-items-center mb-3 gap-2 mt-3">
<input type="text" class="form-control form-control-sm" style="max-width:300px"
placeholder="Filter by name (substring)…"
@bind="_filter" @bind:event="oninput" />
<span class="text-muted small">
Showing @VisibleRows.Count of @_rows.Count
</span>
</div>
<section class="panel rise" style="animation-delay:.08s">
<div class="panel-head">Tags</div>
@if (VisibleRows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No tags match the current filter.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>TagId</th>
<th>Name</th>
<th>Driver</th>
<th>Equipment</th>
<th>Data type</th>
<th>Access</th>
<th>Folder</th>
<th>Poll group</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var t in VisibleRows)
{
<tr>
<td><span class="mono small">@t.TagId</span></td>
<td>@t.Name</td>
<td><span class="mono small">@t.DriverInstanceId</span></td>
<td>@(t.EquipmentId ?? "—")</td>
<td><span class="mono small">@t.DataType</span></td>
<td>@t.AccessLevel</td>
<td class="text-muted small">@(t.FolderPath ?? "")</td>
<td>@(t.PollGroupId ?? "—")</td>
<td><a href="/clusters/@ClusterId/tags/@t.TagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private const int PageSize = 200;
[Parameter] public string ClusterId { get; set; } = "";
private List<Tag>? _rows;
private string _filter = "";
private List<Tag> VisibleRows => (_rows ?? new())
.Where(t => string.IsNullOrWhiteSpace(_filter)
|| t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase))
.Take(PageSize)
.ToList();
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
// Tags don't carry ClusterId; resolve via DriverInstance scoping.
var driverIds = db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.Select(d => d.DriverInstanceId);
_rows = await db.Tags.AsNoTracking()
.Where(t => driverIds.Contains(t.DriverInstanceId))
.OrderBy(t => t.Name)
.ToListAsync();
}
}
@@ -1,106 +0,0 @@
@page "/clusters/{ClusterId}/uns"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">UNS structure &middot; <span class="mono">@ClusterId</span></h4>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (_areas is null || _lines is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and
lines are cluster-scoped; equipment hangs under a single line.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head d-flex align-items-center">
<span>Areas (level 3) &middot; @_areas.Count</span>
<a href="/clusters/@ClusterId/uns/areas/new" class="btn btn-sm btn-outline-primary ms-auto">New area</a>
</div>
@if (_areas.Count == 0)
{
<div style="padding:1rem" class="text-muted">No areas defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>UnsAreaId</th><th>Name</th><th>Notes</th><th></th></tr></thead>
<tbody>
@foreach (var a in _areas)
{
<tr>
<td><span class="mono">@a.UnsAreaId</span></td>
<td>@a.Name</td>
<td class="text-muted small">@(a.Notes ?? "")</td>
<td><a href="/clusters/@ClusterId/uns/areas/@a.UnsAreaId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head d-flex align-items-center">
<span>Lines (level 4) &middot; @_lines.Count</span>
<a href="/clusters/@ClusterId/uns/lines/new" class="btn btn-sm btn-outline-primary ms-auto">New line</a>
</div>
@if (_lines.Count == 0)
{
<div style="padding:1rem" class="text-muted">No lines defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>UnsLineId</th><th>Name</th><th>Area</th><th>Notes</th><th></th></tr></thead>
<tbody>
@foreach (var l in _lines)
{
<tr>
<td><span class="mono">@l.UnsLineId</span></td>
<td>@l.Name</td>
<td><span class="mono">@l.UnsAreaId</span></td>
<td class="text-muted small">@(l.Notes ?? "")</td>
<td><a href="/clusters/@ClusterId/uns/lines/@l.UnsLineId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<UnsArea>? _areas;
private List<UnsLine>? _lines;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_areas = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.UnsAreaId)
.ToListAsync();
var areaIds = _areas.Select(a => a.UnsAreaId).ToList();
_lines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
.ToListAsync();
}
}
@@ -1,309 +0,0 @@
@page "/clusters/{ClusterId}/equipment/new"
@page "/clusters/{ClusterId}/equipment/{EquipmentId}"
@* Equipment CRUD. EquipmentId is system-generated (decision #125) — operator picks Name +
MachineCode + UnsLine + Driver; the EquipmentId is derived from the EquipmentUuid on create.
OPC 40010 identification fields (Manufacturer, Model, etc.) are all optional. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New equipment" : "Edit equipment") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Equipment <span class="mono">@EquipmentId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="equipmentEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">EquipmentId</label>
<input class="form-control form-control-sm mono" value="@EquipmentId" disabled />
<div class="form-text">System-generated; never operator-edited.</div>
</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm mono"
placeholder="machine-01" />
<div class="form-text">UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="machinecode">MachineCode</label>
<InputText id="machinecode" @bind-Value="_form.MachineCode" class="form-control form-control-sm mono"
placeholder="machine_001" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="line">UNS line</label>
<InputSelect id="line" @bind-Value="_form.UnsLineId" class="form-select form-select-sm">
<option value="">— pick a line —</option>
@foreach (var l in _lines)
{
<option value="@l.UnsLineId">@l.UnsAreaId / @l.UnsLineId &mdash; @l.Name</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name (@d.DriverType)</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="ztag">ZTag (ERP)</label>
<InputText id="ztag" @bind-Value="_form.ZTag" class="form-control form-control-sm" />
<div class="form-text">Unique fleet-wide via ExternalIdReservation.</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="sap">SAPID</label>
<InputText id="sap" @bind-Value="_form.SAPID" class="form-control form-control-sm" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Surface in deployments</label>
</div>
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">OPC 40010 identification (optional)</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-4 mb-3"><label class="form-label">Manufacturer</label><InputText @bind-Value="_form.Manufacturer" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">Model</label><InputText @bind-Value="_form.Model" class="form-control form-control-sm" /></div>
<div class="col-md-4 mb-3"><label class="form-label">SerialNumber</label><InputText @bind-Value="_form.SerialNumber" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-3 mb-3"><label class="form-label">HardwareRevision</label><InputText @bind-Value="_form.HardwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">SoftwareRevision</label><InputText @bind-Value="_form.SoftwareRevision" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">Year of construction</label><InputNumber @bind-Value="_form.YearOfConstruction" class="form-control form-control-sm" /></div>
<div class="col-md-3 mb-3"><label class="form-label">AssetLocation</label><InputText @bind-Value="_form.AssetLocation" class="form-control form-control-sm" /></div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">ManufacturerUri</label><InputText @bind-Value="_form.ManufacturerUri" class="form-control form-control-sm mono" /></div>
<div class="col-md-6 mb-3"><label class="form-label">DeviceManualUri</label><InputText @bind-Value="_form.DeviceManualUri" class="form-control form-control-sm mono" /></div>
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? EquipmentId { get; set; }
private bool IsNew => string.IsNullOrEmpty(EquipmentId);
private FormModel _form = new();
private Equipment? _existing;
private List<UnsLine> _lines = new();
private List<DriverInstance> _drivers = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var areaIds = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId).ToListAsync();
_lines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId)
.ToListAsync();
_drivers = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
if (!IsNew)
{
_existing = await db.Equipment.AsNoTracking()
.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (_existing is not null)
{
_form = new FormModel
{
Name = _existing.Name,
MachineCode = _existing.MachineCode,
UnsLineId = _existing.UnsLineId,
DriverInstanceId = _existing.DriverInstanceId,
ZTag = _existing.ZTag,
SAPID = _existing.SAPID,
Manufacturer = _existing.Manufacturer,
Model = _existing.Model,
SerialNumber = _existing.SerialNumber,
HardwareRevision = _existing.HardwareRevision,
SoftwareRevision = _existing.SoftwareRevision,
YearOfConstruction = _existing.YearOfConstruction,
AssetLocation = _existing.AssetLocation,
ManufacturerUri = _existing.ManufacturerUri,
DeviceManualUri = _existing.DeviceManualUri,
Enabled = _existing.Enabled,
RowVersion = _existing.RowVersion,
};
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
if (await db.Equipment.AnyAsync(e => e.MachineCode == _form.MachineCode))
{ _error = $"MachineCode '{_form.MachineCode}' already exists in this fleet."; return; }
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId,
UnsLineId = _form.UnsLineId,
Name = _form.Name,
MachineCode = _form.MachineCode,
ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag,
SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID,
Manufacturer = _form.Manufacturer,
Model = _form.Model,
SerialNumber = _form.SerialNumber,
HardwareRevision = _form.HardwareRevision,
SoftwareRevision = _form.SoftwareRevision,
YearOfConstruction = _form.YearOfConstruction,
AssetLocation = _form.AssetLocation,
ManufacturerUri = _form.ManufacturerUri,
DeviceManualUri = _form.DeviceManualUri,
Enabled = _form.Enabled,
});
}
else
{
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId;
entity.UnsLineId = _form.UnsLineId;
entity.Name = _form.Name;
entity.MachineCode = _form.MachineCode;
entity.ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag;
entity.SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID;
entity.Manufacturer = _form.Manufacturer;
entity.Model = _form.Model;
entity.SerialNumber = _form.SerialNumber;
entity.HardwareRevision = _form.HardwareRevision;
entity.SoftwareRevision = _form.SoftwareRevision;
entity.YearOfConstruction = _form.YearOfConstruction;
entity.AssetLocation = _form.AssetLocation;
entity.ManufacturerUri = _form.ManufacturerUri;
entity.DeviceManualUri = _form.DeviceManualUri;
entity.Enabled = _form.Enabled;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.Equipment.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")]
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public bool Enabled { get; set; } = true;
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,260 +0,0 @@
@page "/clusters/{ClusterId}/equipment/import"
@* Bulk equipment import via pasted CSV. Header row required; columns:
Name, MachineCode, UnsLineId, DriverInstanceId, ZTag, SAPID, Manufacturer, Model
Empty optional columns parsed as null. EquipmentId is system-generated per row
(matches single-add path in EquipmentEdit.razor). *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Import equipment &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="equipment" />
<section class="panel notice rise" style="animation-delay:.02s">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Bulk import requires a driver; driver-less (VirtualTag-only) equipment is created via the single-add form.
Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are
detected by MachineCode and skipped (the importer is additive-only — no updates).
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">CSV</div>
<div style="padding:1rem">
<textarea class="form-control form-control-sm mono" rows="14"
@bind="_csv" @bind:event="oninput"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,line-3,drv-modbus-line3-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
@if (_preview is not null)
{
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Preview &middot; @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import</div>
@if (_preview.Count > 0)
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>MachineCode</th>
<th>UNS line</th>
<th>Driver</th>
<th>ZTag</th>
<th>SAPID</th>
<th>Manufacturer</th>
<th>Model</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var p in _preview)
{
<tr>
<td>@p.Name</td>
<td><span class="mono">@p.MachineCode</span></td>
<td><span class="mono small">@p.UnsLineId</span></td>
<td><span class="mono small">@p.DriverInstanceId</span></td>
<td>@(p.ZTag ?? "")</td>
<td>@(p.SAPID ?? "")</td>
<td>@(p.Manufacturer ?? "")</td>
<td>@(p.Model ?? "")</td>
<td>
@if (p.IsSkipped) { <span class="chip chip-idle">skip — exists</span> }
else if (!string.IsNullOrEmpty(p.RowError)) { <span class="chip chip-alert">@p.RowError</span> }
else { <span class="chip chip-ok">ready</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-primary" @onclick="PreviewAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Preview
</button>
<button class="btn btn-primary" @onclick="ImportAsync"
disabled="@(_busy || _preview is null || _preview.All(p => p.IsSkipped || !string.IsNullOrEmpty(p.RowError)))">
Import @(_preview?.Count(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)) ?? 0) row(s)
</button>
<a href="/clusters/@ClusterId/equipment" class="btn btn-outline-secondary">Cancel</a>
</div>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private string _csv = "";
private List<PreviewRow>? _preview;
private bool _busy;
private string? _error;
// Bulk import requires a DriverInstanceId by design — every CSV row must reference an existing driver.
// Driver-less equipment (DriverInstanceId == null) is not supported via bulk import;
// create it via the single-add editor (/clusters/{id}/equipment/new) or the SQL loader.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"];
private async Task PreviewAsync()
{
_busy = true;
_error = null;
_preview = null;
try
{
var parsed = ParseCsv(_csv);
if (parsed is null) return;
await using var db = await DbFactory.CreateDbContextAsync();
var driversInCluster = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.Select(d => d.DriverInstanceId)
.ToListAsync();
var driverSet = driversInCluster.ToHashSet(StringComparer.Ordinal);
var areaIds = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.Select(a => a.UnsAreaId).ToListAsync();
var validLines = await db.UnsLines.AsNoTracking()
.Where(l => areaIds.Contains(l.UnsAreaId))
.Select(l => l.UnsLineId).ToListAsync();
var lineSet = validLines.ToHashSet(StringComparer.Ordinal);
var existingMachineCodes = await db.Equipment.AsNoTracking()
.Select(e => e.MachineCode).ToListAsync();
var existingSet = existingMachineCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var row in parsed)
{
if (existingSet.Contains(row.MachineCode))
{
row.IsSkipped = true;
continue;
}
if (!driverSet.Contains(row.DriverInstanceId))
{
row.RowError = $"driver '{row.DriverInstanceId}' not in this cluster";
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
row.RowError = $"UNS line '{row.UnsLineId}' not in this cluster";
}
}
_preview = parsed;
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task ImportAsync()
{
if (_preview is null) return;
_busy = true;
_error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var added = 0;
foreach (var row in _preview.Where(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError)))
{
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = row.DriverInstanceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
Enabled = true,
});
added++;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/equipment");
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private List<PreviewRow>? ParseCsv(string csv)
{
if (string.IsNullOrWhiteSpace(csv)) { _error = "CSV is empty."; return null; }
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) { _error = "Need a header row and at least one data row."; return null; }
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
for (var i = 0; i < RequiredColumns.Length; i++)
{
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
{
_error = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
return null;
}
}
var rows = new List<PreviewRow>();
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
{
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
if (parts.Length < RequiredColumns.Length)
{
rows.Add(new PreviewRow { RowError = $"too few columns (got {parts.Length}, need {RequiredColumns.Length})" });
continue;
}
rows.Add(new PreviewRow
{
Name = parts[0],
MachineCode = parts[1],
UnsLineId = parts[2],
DriverInstanceId = parts[3],
ZTag = NullIfEmpty(parts, 4),
SAPID = NullIfEmpty(parts, 5),
Manufacturer = NullIfEmpty(parts, 6),
Model = NullIfEmpty(parts, 7),
});
}
return rows;
}
private static string? NullIfEmpty(string[] parts, int idx) =>
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
private sealed class PreviewRow
{
public string Name { get; set; } = "";
public string MachineCode { get; set; } = "";
public string UnsLineId { get; set; } = "";
public string DriverInstanceId { get; set; } = "";
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public bool IsSkipped { get; set; }
public string? RowError { get; set; }
}
}
@@ -1,320 +0,0 @@
@page "/clusters/{ClusterId}/tags/new"
@page "/clusters/{ClusterId}/tags/{TagId}"
@* Tag CRUD. EquipmentId is required when the chosen driver's namespace is Equipment-kind,
forbidden when SystemPlatform-kind (decision #110); the form switches between
"pick equipment" and "FolderPath input" based on namespace kind. *@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New tag" : "Edit tag") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="tags" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Tag <span class="mono">@TagId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="tagEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tagId">TagId</label>
<InputText id="tagId" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="dtype">Data type</label>
<InputSelect id="dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
@{ var driverNamespace = ResolveDriverNamespace(_form.DriverInstanceId); }
@if (driverNamespace?.Kind == NamespaceKind.Equipment)
{
<div class="mb-3">
<label class="form-label" for="equipment">Equipment</label>
<InputSelect id="equipment" @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
<option value="">— pick equipment —</option>
@foreach (var e in _equipment.Where(e => e.DriverInstanceId == _form.DriverInstanceId))
{
<option value="@e.EquipmentId">@e.MachineCode &mdash; @e.Name</option>
}
</InputSelect>
</div>
}
else if (driverNamespace?.Kind == NamespaceKind.SystemPlatform)
{
<div class="mb-3">
<label class="form-label" for="folder">FolderPath (SystemPlatform namespace)</label>
<InputText id="folder" @bind-Value="_form.FolderPath" class="form-control form-control-sm mono"
placeholder="GalaxyArea/Machine_001" />
<div class="form-text">Galaxy hierarchy preserved as v1 expressed it — no UNS rule.</div>
</div>
}
else if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="text-muted small mb-3">Pick a driver to see its namespace kind.</div>
}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="access">Access level</label>
<InputSelect id="access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="pgroup">PollGroupId (optional)</label>
<InputText id="pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
</div>
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Tag config (JSON)</div>
<div style="padding:1rem">
<InputTextArea @bind-Value="_form.TagConfig" rows="8"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/tags" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? TagId { get; set; }
private bool IsNew => string.IsNullOrEmpty(TagId);
private FormModel _form = new();
private Tag? _existing;
private List<DriverInstance> _drivers = new();
private List<Equipment> _equipment = new();
private Dictionary<string, Namespace> _namespacesByDriverInstance = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_drivers = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
var namespaces = await db.Namespaces.AsNoTracking()
.Where(n => n.ClusterId == ClusterId)
.ToListAsync();
var nsById = namespaces.ToDictionary(n => n.NamespaceId);
_namespacesByDriverInstance = _drivers.ToDictionary(
d => d.DriverInstanceId,
d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First());
var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet();
_equipment = await db.Equipment.AsNoTracking()
.Where(e => e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId))
.OrderBy(e => e.MachineCode)
.ToListAsync();
if (!IsNew)
{
_existing = await db.Tags.AsNoTracking()
.FirstOrDefaultAsync(t => t.TagId == TagId);
if (_existing is not null)
{
_form = new FormModel
{
TagId = _existing.TagId,
Name = _existing.Name,
DriverInstanceId = _existing.DriverInstanceId,
EquipmentId = _existing.EquipmentId,
FolderPath = _existing.FolderPath,
DataType = _existing.DataType,
AccessLevel = _existing.AccessLevel,
WriteIdempotent = _existing.WriteIdempotent,
PollGroupId = _existing.PollGroupId,
TagConfig = _existing.TagConfig,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.DataType = "Float";
_form.AccessLevel = TagAccessLevel.Read;
_form.TagConfig = "{}";
}
_loaded = true;
}
private Namespace? ResolveDriverNamespace(string driverId) =>
string.IsNullOrEmpty(driverId) ? null
: _namespacesByDriverInstance.TryGetValue(driverId, out var ns) ? ns : null;
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver."; return; }
var ns = ResolveDriverNamespace(_form.DriverInstanceId);
if (ns?.Kind == NamespaceKind.Equipment && string.IsNullOrEmpty(_form.EquipmentId))
{ _error = "Driver lives in an Equipment-kind namespace — pick an equipment."; return; }
if (ns?.Kind == NamespaceKind.SystemPlatform && !string.IsNullOrEmpty(_form.EquipmentId))
{ _error = "Driver lives in a SystemPlatform namespace — EquipmentId must be empty (use FolderPath)."; return; }
try { using var _ = System.Text.Json.JsonDocument.Parse(_form.TagConfig); }
catch { _error = "TagConfig is not valid JSON."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.Tags.AnyAsync(t => t.TagId == _form.TagId))
{ _error = $"Tag '{_form.TagId}' already exists."; return; }
db.Tags.Add(new Tag
{
TagId = _form.TagId,
DriverInstanceId = _form.DriverInstanceId,
EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId,
Name = _form.Name,
FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath,
DataType = _form.DataType,
AccessLevel = _form.AccessLevel,
WriteIdempotent = _form.WriteIdempotent,
PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
TagConfig = _form.TagConfig,
});
}
else
{
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = _form.DriverInstanceId;
entity.EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId;
entity.Name = _form.Name;
entity.FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath;
entity.DataType = _form.DataType;
entity.AccessLevel = _form.AccessLevel;
entity.WriteIdempotent = _form.WriteIdempotent;
entity.PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId;
entity.TagConfig = _form.TagConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/tags"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.Tags.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string? EquipmentId { get; set; }
public string? FolderPath { get; set; }
[Required] public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,167 +0,0 @@
@page "/clusters/{ClusterId}/uns/areas/new"
@page "/clusters/{ClusterId}/uns/areas/{UnsAreaId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New UNS area" : "Edit UNS area") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Area <span class="mono">@UnsAreaId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsAreaEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">UNS area (level 3)</div>
<div style="padding:1rem">
<div class="mb-3">
<label class="form-label" for="aid">UnsAreaId</label>
<InputText id="aid" @bind-Value="_form.UnsAreaId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
<div class="mb-3">
<label class="form-label" for="notes">Notes</label>
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? UnsAreaId { get; set; }
private bool IsNew => string.IsNullOrEmpty(UnsAreaId);
private FormModel _form = new();
private UnsArea? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
if (!IsNew)
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.UnsAreas.AsNoTracking()
.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (_existing is not null)
{
_form = new FormModel
{
UnsAreaId = _existing.UnsAreaId,
Name = _existing.Name,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == _form.UnsAreaId))
{ _error = $"Area '{_form.UnsAreaId}' already exists."; return; }
db.UnsAreas.Add(new UnsArea
{
UnsAreaId = _form.UnsAreaId,
ClusterId = ClusterId,
Name = _form.Name,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.UnsAreas.FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _form.Name;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were editing. Reload to see the latest values."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.UnsAreas.FirstOrDefaultAsync(
a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.UnsAreas.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,187 +0,0 @@
@page "/clusters/{ClusterId}/uns/lines/new"
@page "/clusters/{ClusterId}/uns/lines/{UnsLineId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New UNS line" : "Edit UNS line") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="uns" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Line <span class="mono">@UnsLineId</span> was not found.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="unsLineEdit">
<DataAnnotationsValidator />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">UNS line (level 4)</div>
<div style="padding:1rem">
<div class="mb-3">
<label class="form-label" for="lid">UnsLineId</label>
<InputText id="lid" @bind-Value="_form.UnsLineId" disabled="@(!IsNew)"
class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="area">Parent area</label>
<InputSelect id="area" @bind-Value="_form.UnsAreaId" class="form-select form-select-sm">
@foreach (var area in _areas)
{
<option value="@area.UnsAreaId">@area.UnsAreaId &mdash; @area.Name</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label" for="name">Name</label>
<InputText id="name" @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
<div class="mb-3">
<label class="form-label" for="notes">Notes</label>
<InputTextArea id="notes" @bind-Value="_form.Notes" class="form-control form-control-sm" rows="3" />
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div>
}
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
<a href="/clusters/@ClusterId/uns" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew)
{
<button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button>
}
</div>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? UnsLineId { get; set; }
private bool IsNew => string.IsNullOrEmpty(UnsLineId);
private FormModel _form = new();
private UnsLine? _existing;
private List<UnsArea> _areas = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_areas = await db.UnsAreas.AsNoTracking()
.Where(a => a.ClusterId == ClusterId)
.OrderBy(a => a.UnsAreaId)
.ToListAsync();
if (!IsNew)
{
_existing = await db.UnsLines.AsNoTracking()
.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (_existing is not null)
{
_form = new FormModel
{
UnsLineId = _existing.UnsLineId,
UnsAreaId = _existing.UnsAreaId,
Name = _existing.Name,
Notes = _existing.Notes,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.UnsAreaId = _areas.FirstOrDefault()?.UnsAreaId ?? "";
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.UnsLines.AnyAsync(l => l.UnsLineId == _form.UnsLineId))
{ _error = $"Line '{_form.UnsLineId}' already exists."; return; }
db.UnsLines.Add(new UnsLine
{
UnsLineId = _form.UnsLineId,
UnsAreaId = _form.UnsAreaId,
Name = _form.Name,
Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes,
});
}
else
{
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.UnsAreaId = _form.UnsAreaId;
entity.Name = _form.Name;
entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.UnsLines.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/uns");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were viewing it."; }
catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = "";
[Required] public string UnsAreaId { get; set; } = "";
[Required] public string Name { get; set; } = "";
public string? Notes { get; set; }
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,231 +0,0 @@
@page "/virtual-tags/new"
@page "/virtual-tags/{VirtualTagId}"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New virtual tag" : "Edit virtual tag")</h4>
<a href="/virtual-tags" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise"><span class="mono">@VirtualTagId</span> not found.</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="vtagEdit">
<DataAnnotationsValidator />
<section class="panel rise">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">VirtualTagId</label>
<InputText @bind-Value="_form.VirtualTagId" disabled="@(!IsNew)" class="form-control form-control-sm mono" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Name</label>
<InputText @bind-Value="_form.Name" class="form-control form-control-sm" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Equipment</label>
<InputSelect @bind-Value="_form.EquipmentId" class="form-select form-select-sm">
<option value="">— pick equipment —</option>
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.MachineCode &mdash; @e.Name</option> }
</InputSelect>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">DataType</label>
<InputText @bind-Value="_form.DataType" class="form-control form-control-sm mono" placeholder="Double" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Script</label>
<InputSelect @bind-Value="_form.ScriptId" class="form-select form-select-sm">
<option value="">— pick script —</option>
@foreach (var s in _scripts) { <option value="@s.ScriptId">@s.Name (@s.Language)</option> }
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.ChangeTriggered" class="form-check-input" />
<label class="form-check-label">Re-evaluate on dependency change</label>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">TimerIntervalMs (optional)</label>
<InputNumber @bind-Value="_form.TimerIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Periodic re-evaluation. Null = change-trigger only.</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Historize</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Historize" class="form-check-input" />
<label class="form-check-label">Send to Wonderware historian</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Enabled</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.Enabled" class="form-check-input" />
<label class="form-check-label">Spawn this virtual tag in deployments</label>
</div>
</div>
</div>
</section>
@if (!string.IsNullOrWhiteSpace(_error)) { <div class="panel notice mt-3" style="border-color:var(--alert)">@_error</div> }
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@_busy">@(IsNew ? "Create" : "Save changes")</button>
<a href="/virtual-tags" class="btn btn-outline-secondary">Cancel</a>
@if (!IsNew) { <button type="button" class="btn btn-outline-danger ms-auto" @onclick="DeleteAsync" disabled="@_busy">Delete</button> }
</div>
</EditForm>
}
@code {
[Parameter] public string? VirtualTagId { get; set; }
private bool IsNew => string.IsNullOrEmpty(VirtualTagId);
private FormModel _form = new();
private VirtualTag? _existing;
private List<Equipment> _equipment = new();
private List<Script> _scripts = new();
private bool _loaded;
private bool _busy;
private string? _error;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_equipment = await db.Equipment.AsNoTracking().OrderBy(e => e.MachineCode).ToListAsync();
_scripts = await db.Scripts.AsNoTracking().OrderBy(s => s.Name).ToListAsync();
if (!IsNew)
{
_existing = await db.VirtualTags.AsNoTracking().FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (_existing is not null)
{
_form = new FormModel
{
VirtualTagId = _existing.VirtualTagId,
Name = _existing.Name,
EquipmentId = _existing.EquipmentId,
DataType = _existing.DataType,
ScriptId = _existing.ScriptId,
ChangeTriggered = _existing.ChangeTriggered,
TimerIntervalMs = _existing.TimerIntervalMs,
Historize = _existing.Historize,
Enabled = _existing.Enabled,
RowVersion = _existing.RowVersion,
};
}
}
else
{
_form.DataType = "Double";
_form.ChangeTriggered = true;
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
if (string.IsNullOrEmpty(_form.EquipmentId)) { _error = "Pick equipment."; return; }
if (string.IsNullOrEmpty(_form.ScriptId)) { _error = "Pick a script."; return; }
if (!_form.ChangeTriggered && _form.TimerIntervalMs is null)
{ _error = "Pick at least one trigger — change or timer."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == _form.VirtualTagId))
{ _error = $"VirtualTag '{_form.VirtualTagId}' already exists."; return; }
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = _form.VirtualTagId,
EquipmentId = _form.EquipmentId,
Name = _form.Name,
DataType = _form.DataType,
ScriptId = _form.ScriptId,
ChangeTriggered = _form.ChangeTriggered,
TimerIntervalMs = _form.TimerIntervalMs,
Historize = _form.Historize,
Enabled = _form.Enabled,
});
}
else
{
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.EquipmentId = _form.EquipmentId;
entity.Name = _form.Name;
entity.DataType = _form.DataType;
entity.ScriptId = _form.ScriptId;
entity.ChangeTriggered = _form.ChangeTriggered;
entity.TimerIntervalMs = _form.TimerIntervalMs;
entity.Historize = _form.Historize;
entity.Enabled = _form.Enabled;
}
await db.SaveChangesAsync();
Nav.NavigateTo("/virtual-tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were editing."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == VirtualTagId);
if (entity is null) { Nav.NavigateTo("/virtual-tags"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.VirtualTags.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo("/virtual-tags");
}
catch (DbUpdateConcurrencyException) { _error = "Another user changed this virtual tag while you were viewing it."; }
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string VirtualTagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string EquipmentId { get; set; } = "";
[Required] public string DataType { get; set; } = "Double";
[Required] public string ScriptId { get; set; } = "";
public bool ChangeTriggered { get; set; } = true;
public int? TimerIntervalMs { get; set; }
public bool Historize { get; set; }
public bool Enabled { get; set; } = true;
public byte[] RowVersion { get; set; } = [];
}
}
@@ -1,85 +0,0 @@
@page "/virtual-tags"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Virtual tags</h4>
<a href="/virtual-tags/new" class="btn btn-primary btn-sm">New virtual tag</a>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel notice rise" style="animation-delay:.02s">
Virtual tags evaluate a script per equipment instance and publish the result as an OPC UA
variable. ChangeTriggered = re-evaluate when any dependency changes; TimerIntervalMs
re-evaluates on a periodic timer.
</section>
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count virtual tag@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No virtual tags defined.</div>
}
else
{
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>VirtualTagId</th>
<th>Name</th>
<th>Equipment</th>
<th>Data type</th>
<th>Script</th>
<th>Trigger</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var v in _rows)
{
<tr>
<td><span class="mono small">@v.VirtualTagId</span></td>
<td>@v.Name</td>
<td><span class="mono small">@v.EquipmentId</span></td>
<td><span class="mono small">@v.DataType</span></td>
<td><span class="mono small">@v.ScriptId</span></td>
<td>
@if (v.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
@if (v.TimerIntervalMs is int ms) { <span class="chip chip-idle">@(ms)ms</span> }
</td>
<td>
@if (v.Enabled) { <span class="chip chip-ok">Enabled</span> }
else { <span class="chip chip-idle">Disabled</span> }
</td>
<td><a href="/virtual-tags/@v.VirtualTagId" class="btn btn-sm btn-outline-primary">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
@code {
private List<VirtualTag>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.VirtualTags.AsNoTracking()
.OrderBy(v => v.Name)
.ToListAsync();
}
}