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:
@@ -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 · <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 · <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 · <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) · @_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) · @_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") · <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 — @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 — @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 · <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 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 · @_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") · <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 — @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 — @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 #44–45)</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") · <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") · <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 — @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 — @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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user