feat(adminui): AliasTagModal + Tags-tab Add-alias (Galaxy picker)

This commit is contained in:
Joseph Doherty
2026-06-11 21:37:04 -04:00
parent 943bc5f709
commit 4af27ea173
2 changed files with 309 additions and 3 deletions
@@ -138,7 +138,12 @@ else
}
else if (_activeTab == "tags")
{
<div class="d-flex justify-content-end mb-2">
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
@if (_gateways.Count == 0)
{
<span class="text-muted small">No Galaxy gateway in this cluster</span>
}
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddAlias" disabled="@(_gateways.Count == 0)">Add alias (browse Galaxy)</button>
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
</div>
@if (!string.IsNullOrWhiteSpace(_tagError))
@@ -157,7 +162,7 @@ else
{
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th>Source</th><th class="text-end">Actions</th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
@@ -167,8 +172,22 @@ else
<td class="mono">@t.DriverInstanceId</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td>
@if (t.IsAlias)
{
<span class="badge bg-info me-1">alias</span>
<span class="mono small">@t.Source</span>
}
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
@if (t.IsAlias)
{
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditAlias(t.TagId)">Edit</button>
}
else
{
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
}
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
</td>
</tr>
@@ -180,6 +199,10 @@ else
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
<AliasTagModal Visible="_aliasModalVisible" IsNew="_aliasModalIsNew" EquipmentId="@EquipmentId"
Existing="_aliasModalExisting" Gateways="_gateways"
OnSaved="OnAliasSavedAsync" OnCancel="@(() => { _aliasModalVisible = false; })" />
}
else if (_activeTab == "vtags")
{
@@ -300,6 +323,14 @@ else
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagDriverOptions = Array.Empty<(string, string, string)>();
// --- Alias-tag modal state (Galaxy alias = an equipment Tag bound to a GalaxyMxGateway driver). The
// gateways are reloaded alongside _tags so the Add-alias button can disable itself when none exist. ---
private IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)> _gateways
= Array.Empty<(string, string, string)>();
private bool _aliasModalVisible;
private bool _aliasModalIsNew;
private AliasTagEditDto? _aliasModalExisting;
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
private string? _vtagError;
@@ -339,6 +370,8 @@ else
private async Task ReloadTagsAsync()
{
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
// Also refresh the candidate Galaxy gateways so the Add-alias affordance reflects the cluster.
_gateways = await Svc.LoadGalaxyGatewaysForEquipmentAsync(EquipmentId!);
}
private async Task OpenAddTag()
@@ -379,6 +412,33 @@ else
else { _tagError = r.Error; }
}
// --- Alias-tag handlers (mirror the tag handlers; aliases live in the same _tags list + delete the
// same way, so only the create/edit modal differs — it embeds the Galaxy live-browse picker). ---
private void OpenAddAlias()
{
_tagError = null;
_aliasModalIsNew = true;
_aliasModalExisting = null;
_aliasModalVisible = true;
}
private async Task OpenEditAlias(string tagId)
{
_tagError = null;
var dto = await Svc.LoadAliasTagAsync(tagId);
if (dto is null) { _tagError = "That alias no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_aliasModalIsNew = false;
_aliasModalExisting = dto;
_aliasModalVisible = true;
}
private async Task OnAliasSavedAsync()
{
_aliasModalVisible = false;
await ReloadTagsAsync();
}
// --- Virtual Tags tab handlers ---
private async Task ReloadVirtualTagsAsync()
@@ -0,0 +1,246 @@
@* Create/edit modal for a Galaxy alias tag — an equipment Tag bound to a GalaxyMxGateway driver that
surfaces a Galaxy attribute (its tag_name.AttributeName reference) under a friendly UNS name. A focused
sibling of TagModal: it drops the generic driver/tag-config surface and instead embeds the Galaxy
live-browse picker so the operator picks the Galaxy reference straight into FullName. The host page owns
visibility and supplies the owning equipment id (create) or the loaded AliasTagEditDto (edit), plus the
equipment's candidate Galaxy gateways. On a successful save it raises OnSaved so the host can refresh the
equipment's tags in place. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="aliasTagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New alias" : "Edit alias")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="alias-name">Name</label>
<InputText id="alias-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Download path" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="alias-gateway">Galaxy gateway</label>
<InputSelect id="alias-gateway" @bind-Value="_form.DriverInstanceId"
@bind-Value:after="OnGatewayChanged" class="form-select form-select-sm">
<option value="">— pick a gateway —</option>
@foreach (var (id, display, _) in Gateways)
{
<option value="@id">@display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="alias-dtype">Data type</label>
<InputSelect id="alias-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="alias-access">Access level</label>
<InputSelect id="alias-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>
<div class="mb-3">
<label class="form-label" for="alias-fullname">Galaxy reference</label>
<div class="input-group input-group-sm">
<InputText id="alias-fullname" @bind-Value="_form.FullName" class="form-control form-control-sm mono"
placeholder="DelmiaReceiver_001.DownloadPath" />
<button type="button" class="btn btn-outline-secondary"
@onclick="@(() => _showPicker = true)"
disabled="@string.IsNullOrEmpty(_form.DriverInstanceId)">
Browse Galaxy
</button>
</div>
<div class="form-text">
The <code>tag_name.AttributeName</code> reference this alias surfaces. Type it directly
or pick a gateway above and browse.
</div>
<ValidationMessage For="@(() => _form.FullName)" />
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_form.FullName"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_form.FullName"
CurrentAddressChanged="@((s) => _form.FullName = s)"
GetConfigJson="@(() => _selectedGatewayConfig)" />
</DriverTagPicker>
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<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>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
// Same OPC UA built-in type names the TagModal offers, kept in sync.
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new alias; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created alias binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The alias being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public AliasTagEditDto? Existing { get; set; }
/// <summary>The candidate Galaxy gateways — scoped to the equipment's cluster by the host — as
/// <c>(DriverInstanceId, Display, DriverConfig)</c> triples. The config feeds the live-browse picker.</summary>
[Parameter] public IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)> Gateways { get; set; }
= Array.Empty<(string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's tags and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
private bool _showPicker;
// The selected gateway's DriverConfig JSON — fed to the Galaxy picker so it browses the right gateway.
private string _selectedGatewayConfig = "{}";
// Tracks which open this modal last loaded for, so unrelated Blazor Server re-renders don't rebuild
// _form and clobber in-progress edits. Null while closed.
private string? _loadedKey;
protected override void OnParametersSet()
{
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
return;
}
// Guard against unrelated re-renders. In Blazor Server any live-status push re-invokes
// OnParametersSet; without this the rebuild below would silently discard whatever the operator
// has typed. Only rebuild when the modal OPENS or the target entity CHANGES.
var key = IsNew ? "<new>" : Existing?.TagId;
if (key == _loadedKey) return; // same open, re-render → preserve in-progress form edits
_loadedKey = key;
if (IsNew)
{
_form = new FormModel();
// Auto-select when exactly one gateway, so the picker can browse without an extra click.
if (Gateways.Count == 1) { _form.DriverInstanceId = Gateways[0].DriverInstanceId; }
}
else if (Existing is not null)
{
_form = new FormModel
{
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
FullName = Existing.FullName,
};
}
_error = null;
_showPicker = false;
SyncSelectedGatewayConfig();
}
// Keeps the picker's browse config aligned with the chosen gateway whenever the operator switches it.
private void OnGatewayChanged() => SyncSelectedGatewayConfig();
private void SyncSelectedGatewayConfig()
{
var match = Gateways.FirstOrDefault(g => g.DriverInstanceId == _form.DriverInstanceId);
_selectedGatewayConfig = string.IsNullOrEmpty(match.DriverConfig) ? "{}" : match.DriverConfig;
}
private void OnAddressPicked(string address) => _form.FullName = address;
private async Task SaveAsync()
{
// Client validation: Name, FullName, and a chosen gateway are all required for a usable alias.
if (string.IsNullOrWhiteSpace(_form.Name)) { _error = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.DriverInstanceId)) { _error = "Pick a Galaxy gateway."; return; }
if (string.IsNullOrWhiteSpace(_form.FullName)) { _error = "A Galaxy reference is required — type it or browse to pick one."; return; }
_busy = true;
_error = null;
try
{
// On create the TagId is system-minted (the alias has no operator-typed id field), matching the
// service's own NewTagId() shape: "TAG-" + 12 hex chars. Ignored on update.
var tagId = IsNew ? $"TAG-{Guid.NewGuid().ToString("N")[..12]}" : Existing!.TagId;
var input = new AliasTagInput(tagId, _form.Name, _form.DriverInstanceId, _form.DataType, _form.AccessLevel, _form.FullName);
var result = IsNew
? await Svc.CreateAliasTagAsync(EquipmentId!, input)
: await Svc.UpdateAliasTagAsync(Existing!.TagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
[Required] public string FullName { get; set; } = "";
}
}