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:
@@ -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"/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal file
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/TagService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user