feat(adminui): AliasTagModal + Tags-tab Add-alias (Galaxy picker)
This commit is contained in:
@@ -138,7 +138,12 @@ else
|
|||||||
}
|
}
|
||||||
else if (_activeTab == "tags")
|
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>
|
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(_tagError))
|
@if (!string.IsNullOrWhiteSpace(_tagError))
|
||||||
@@ -157,7 +162,7 @@ else
|
|||||||
{
|
{
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var t in _tags)
|
@foreach (var t in _tags)
|
||||||
@@ -167,8 +172,22 @@ else
|
|||||||
<td class="mono">@t.DriverInstanceId</td>
|
<td class="mono">@t.DriverInstanceId</td>
|
||||||
<td>@t.DataType</td>
|
<td>@t.DataType</td>
|
||||||
<td>@t.AccessLevel</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">
|
<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>
|
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,6 +199,10 @@ else
|
|||||||
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
|
||||||
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
|
||||||
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
|
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")
|
else if (_activeTab == "vtags")
|
||||||
{
|
{
|
||||||
@@ -300,6 +323,14 @@ else
|
|||||||
private TagEditDto? _tagModalExisting;
|
private TagEditDto? _tagModalExisting;
|
||||||
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagDriverOptions = Array.Empty<(string, string, string)>();
|
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. ---
|
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
|
||||||
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
|
||||||
private string? _vtagError;
|
private string? _vtagError;
|
||||||
@@ -339,6 +370,8 @@ else
|
|||||||
private async Task ReloadTagsAsync()
|
private async Task ReloadTagsAsync()
|
||||||
{
|
{
|
||||||
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
|
_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()
|
private async Task OpenAddTag()
|
||||||
@@ -379,6 +412,33 @@ else
|
|||||||
else { _tagError = r.Error; }
|
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 ---
|
// --- Virtual Tags tab handlers ---
|
||||||
|
|
||||||
private async Task ReloadVirtualTagsAsync()
|
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; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user