Task #155 — TagService + TagsTab CRUD UI for Modbus tags

Closes the remaining loop on user-visible Modbus tag editing. Pre-#155 tags
arrived only via SQL seeding or runtime ITagDiscovery; the Admin UI had no
interactive surface for creating / editing / deleting tag rows.

Changes:

- TagService.cs (Admin/Services/) — CRUD wrapper around OtOpcUaConfigDbContext.Tags.
  ListAsync supports optional driver / equipment filters; CreateAsync auto-derives
  TagId; UpdateAsync persists editable fields; DeleteAsync removes the row. Mirrors
  the EquipmentService shape.
- TagsTab.razor (Components/Pages/Clusters/) — list + filter + add/edit/remove form.
  The address/config editor is conditional: when the selected DriverInstance is
  Modbus, ModbusAddressEditor (#145) renders with live-parse preview; otherwise a
  generic JSON textarea (matches the DriversTab pattern from #147). Save-side
  serializes the address-string into TagConfig as `{"addressString":"..."}` JSON.
- ClusterDetail.razor — new "Tags" tab in the cluster-detail nav strip + the routing
  switch.
- Program.cs — TagService registered as a scoped DI service.

Drive-by fix: ModbusDriverFactoryExtensions.CreateInstance promoted from internal
to public — Admin.Tests was using it via reflection-friendly internal access that
broke under the #153 logger overload addition. Public is the right access modifier
anyway since the Server-side bootstrapper calls it from a different assembly.

Drive-by fix #2: ModbusDriverConfigDto was missing MaxReadGap (#143) — surfaced by
the #147 round-trip test that flips MaxReadGap=12 in the view model and asserts
it lands on the resolved options. Added the field + binding line. Confirms #143's
DriverConfig JSON binding was incomplete since the original commit; no production
deployment configured this knob through JSON until now so the gap stayed hidden.

Tests (4 new TagServiceTests):
- Create_And_List_Surfaces_The_Tag — CreateAsync auto-assigns TagId; list returns
  the row.
- List_Filters_By_DriverInstance — driver-scoped filter works.
- Update_Persists_Editable_Fields — Name / DataType / AccessLevel / TagConfig all
  persist through Update.
- Delete_Removes_The_Row — basic delete verification.

113 + 4 (TagService) + 2 (DriversTab round-trip restored after compile fix) = 119
Admin tests green. Solution build clean.

Caveat: bUnit-style render tests for TagsTab still aren't included — Admin.Tests
doesn't have bUnit set up. The TagService logic is fully covered; the razor
component's parser/save glue is exercised by hand at runtime for now.
This commit is contained in:
Joseph Doherty
2026-04-25 01:51:02 -04:00
parent 802366c2c6
commit ec57df1009
6 changed files with 450 additions and 2 deletions

View File

@@ -51,6 +51,7 @@ else
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Tab("tags")" @onclick='() => _tab = "tags"'>Tags</button></li>
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
@@ -89,6 +90,10 @@ else
{
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "tags" && _currentDraft is not null)
{
<TagsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "acls" && _currentDraft is not null)
{
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>

View File

@@ -0,0 +1,271 @@
@using System.Text.Json
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@inject TagService TagSvc
@inject DriverInstanceService DriverSvc
@inject EquipmentService EquipmentSvc
@*
#155 — interactive Tag CRUD scoped to a draft generation. Conditional editor: when the
selected DriverInstance is Modbus, the address input switches to ModbusAddressEditor (#145)
so users get the live-parse preview + grammar validation. Other driver types fall back to
a generic JSON textarea, matching the DriversTab pattern from #147.
*@
<div class="d-flex justify-content-between mb-3">
<h4>Tags (draft gen @GenerationId)</h4>
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label small text-muted">Filter by driver</label>
<select class="form-select form-select-sm" @bind="_filterDriverId" @bind:after="ReloadAsync">
<option value="">— all drivers —</option>
@if (_drivers is not null)
{
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option>
}
}
</select>
</div>
</div>
@if (_tags is null) { <p>Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="text-muted">No tags in this filter.</p> }
else if (_tags.Count > 0)
{
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td>@t.Name</td>
<td><code>@t.DriverInstanceId</code></td>
<td>@(t.EquipmentId ?? "—")</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td class="font-monospace small text-truncate" style="max-width:18rem">@t.TagConfig</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (_showForm)
{
<div class="card mt-3">
<div class="card-body">
<h5>@(_editMode ? "Edit tag" : "New tag")</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
<input class="form-control" @bind="_draft.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">DriverInstance</label>
<select class="form-select" @bind="_draft.DriverInstanceId" @bind:after="OnDriverChanged">
<option value="">— select driver —</option>
@if (_drivers is not null)
{
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option> }
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Equipment (optional)</label>
<select class="form-select" @bind="_draft.EquipmentId">
<option value="">— none (folder-path mode) —</option>
@if (_equipment is not null)
{
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.Name</option> }
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">DataType</label>
<input class="form-control" @bind="_draft.DataType" placeholder="Boolean / Int32 / Float / etc."/>
</div>
<div class="col-md-4">
<label class="form-label">AccessLevel</label>
<select class="form-select" @bind="_draft.AccessLevel">
@foreach (var a in Enum.GetValues<TagAccessLevel>())
{
<option value="@a">@a</option>
}
</select>
</div>
<div class="col-md-4">
<div class="form-check mt-4">
<input type="checkbox" class="form-check-input" @bind="_draft.WriteIdempotent"/>
<label class="form-check-label">WriteIdempotent</label>
</div>
</div>
</div>
<div class="mt-3">
@if (_isModbus)
{
<ModbusAddressEditor @bind-AddressString="_modbusAddress"
@bind-AddressString:after="OnAddressChanged"/>
}
else
{
<label class="form-label">TagConfig (driver-specific JSON or string)</label>
<textarea class="form-control font-monospace" rows="3" @bind="_draft.TagConfig"
placeholder='@("{\"address\": ...}")'></textarea>
}
</div>
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
</div>
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private List<Tag>? _tags;
private List<DriverInstance>? _drivers;
private List<Equipment>? _equipment;
private string _filterDriverId = string.Empty;
private bool _showForm;
private bool _editMode;
private Tag _draft = NewBlankDraft();
private string? _error;
private bool _isModbus;
private string? _modbusAddress;
private static Tag NewBlankDraft() => new()
{
TagId = string.Empty, DriverInstanceId = string.Empty, Name = string.Empty,
DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = string.Empty,
};
protected override async Task OnParametersSetAsync()
{
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
await ReloadAsync();
}
private async Task ReloadAsync()
{
_tags = await TagSvc.ListAsync(GenerationId,
string.IsNullOrWhiteSpace(_filterDriverId) ? null : _filterDriverId,
equipmentId: null,
CancellationToken.None);
}
private void StartAdd()
{
_draft = NewBlankDraft();
_editMode = false;
_modbusAddress = null;
_isModbus = false;
_error = null;
_showForm = true;
}
private void StartEdit(Tag row)
{
_draft = new Tag
{
TagRowId = row.TagRowId,
GenerationId = row.GenerationId,
TagId = row.TagId,
DriverInstanceId = row.DriverInstanceId,
DeviceId = row.DeviceId,
EquipmentId = row.EquipmentId,
Name = row.Name,
FolderPath = row.FolderPath,
DataType = row.DataType,
AccessLevel = row.AccessLevel,
WriteIdempotent = row.WriteIdempotent,
PollGroupId = row.PollGroupId,
TagConfig = row.TagConfig,
};
_editMode = true;
OnDriverChanged();
// Try to extract addressString from existing JSON config so the Modbus editor pre-fills.
if (_isModbus) _modbusAddress = TryExtractAddressString(row.TagConfig);
_error = null;
_showForm = true;
}
private void OnDriverChanged()
{
var driver = _drivers?.FirstOrDefault(d => d.DriverInstanceId == _draft.DriverInstanceId);
_isModbus = driver is not null
&& string.Equals(driver.DriverType, "Modbus", StringComparison.OrdinalIgnoreCase);
}
private void OnAddressChanged()
{
// Sync the address string into TagConfig as a JSON object the factory consumes.
if (string.IsNullOrWhiteSpace(_modbusAddress)) return;
_draft.TagConfig = JsonSerializer.Serialize(new { addressString = _modbusAddress });
}
private static string? TryExtractAddressString(string tagConfig)
{
try
{
using var doc = JsonDocument.Parse(tagConfig);
return doc.RootElement.TryGetProperty("addressString", out var v) ? v.GetString() : null;
}
catch { return null; }
}
private void Cancel()
{
_showForm = false;
_editMode = false;
}
private async Task SaveAsync()
{
_error = null;
try
{
if (string.IsNullOrWhiteSpace(_draft.Name) || string.IsNullOrWhiteSpace(_draft.DriverInstanceId))
{
_error = "Name and DriverInstance are required.";
return;
}
if (_editMode)
await TagSvc.UpdateAsync(_draft, CancellationToken.None);
else
await TagSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
_showForm = false;
_editMode = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
}
private async Task DeleteAsync(Guid id)
{
await TagSvc.DeleteAsync(id, CancellationToken.None);
await ReloadAsync();
}
}

View File

@@ -41,6 +41,7 @@ builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
builder.Services.AddScoped<ClusterService>();
builder.Services.AddScoped<GenerationService>();
builder.Services.AddScoped<EquipmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<UnsService>();
builder.Services.AddScoped<NamespaceService>();
builder.Services.AddScoped<DriverInstanceService>();

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// #155 — Tag CRUD scoped to a draft generation. Tags are the canonical signal definitions
/// (one row per OPC UA variable) the Server materialises into the address space at startup.
/// Mirrors the shape of <see cref="EquipmentService"/>; writes are restricted to draft
/// generations only (published generations are immutable per the validation pipeline).
/// </summary>
public sealed class TagService(OtOpcUaConfigDbContext db)
{
/// <summary>Lists all tags in a generation, ordered by name. Optional driver / equipment filter.</summary>
public Task<List<Tag>> ListAsync(long generationId,
string? driverInstanceId = null,
string? equipmentId = null,
CancellationToken ct = default)
{
var query = db.Tags.AsNoTracking().Where(t => t.GenerationId == generationId);
if (!string.IsNullOrWhiteSpace(driverInstanceId))
query = query.Where(t => t.DriverInstanceId == driverInstanceId);
if (!string.IsNullOrWhiteSpace(equipmentId))
query = query.Where(t => t.EquipmentId == equipmentId);
return query.OrderBy(t => t.Name).ToListAsync(ct);
}
/// <summary>
/// Creates a new tag row in the given draft. TagId is auto-derived as a GUID — the
/// human-friendly Name is the user-facing identifier.
/// </summary>
public async Task<Tag> CreateAsync(long draftId, Tag input, CancellationToken ct)
{
input.GenerationId = draftId;
if (string.IsNullOrWhiteSpace(input.TagId))
input.TagId = Guid.NewGuid().ToString("N");
db.Tags.Add(input);
await db.SaveChangesAsync(ct);
return input;
}
public async Task UpdateAsync(Tag updated, CancellationToken ct)
{
var existing = await db.Tags
.FirstOrDefaultAsync(t => t.TagRowId == updated.TagRowId, ct)
?? throw new InvalidOperationException($"Tag row {updated.TagRowId} not found");
// Editable fields. TagId / GenerationId are immutable; the Validation pipeline rejects
// changes that would break referential integrity (sp_ValidateDraft per decision #110).
existing.Name = updated.Name;
existing.DriverInstanceId = updated.DriverInstanceId;
existing.DeviceId = updated.DeviceId;
existing.EquipmentId = updated.EquipmentId;
existing.FolderPath = updated.FolderPath;
existing.DataType = updated.DataType;
existing.AccessLevel = updated.AccessLevel;
existing.WriteIdempotent = updated.WriteIdempotent;
existing.PollGroupId = updated.PollGroupId;
existing.TagConfig = updated.TagConfig;
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid tagRowId, CancellationToken ct)
{
var existing = await db.Tags.FirstOrDefaultAsync(t => t.TagRowId == tagRowId, ct);
if (existing is null) return;
db.Tags.Remove(existing);
await db.SaveChangesAsync(ct);
}
}

View File

@@ -28,10 +28,12 @@ public static class ModbusDriverFactoryExtensions
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
internal static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
/// <summary>Public for the Server-side bootstrapper + test consumers (Admin.Tests, etc.).</summary>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
internal static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -57,6 +59,7 @@ public static class ModbusDriverFactoryExtensions
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
DisableFC23 = dto.DisableFC23 ?? false,
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
MaxReadGap = dto.MaxReadGap ?? 0,
Family = dto.Family is null ? ModbusFamily.Generic
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
@@ -188,6 +191,7 @@ public static class ModbusDriverFactoryExtensions
public bool? UseFC16ForSingleRegisterWrites { get; init; }
public bool? DisableFC23 { get; init; }
public bool? WriteOnChangeOnly { get; init; }
public ushort? MaxReadGap { get; init; }
public string? Family { get; init; }
public string? MelsecSubFamily { get; init; }
public int? AutoProhibitReprobeMs { get; init; }