feat(adminui): wire Galaxy live-browse picker into the standard TagModal

This commit is contained in:
Joseph Doherty
2026-06-12 22:09:22 -04:00
parent 056bfbda1b
commit 0945f19a50
5 changed files with 115 additions and 18 deletions
@@ -298,7 +298,7 @@ else
private bool _tagModalVisible; private bool _tagModalVisible;
private bool _tagModalIsNew; private bool _tagModalIsNew;
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, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- 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;
@@ -5,9 +5,12 @@
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@ refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@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 @using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc @inject IUnsTreeService Svc
@@ -44,9 +47,9 @@
<label class="form-label" for="tag-driver">Driver instance</label> <label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm"> <InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
<option value="">— pick a driver —</option> <option value="">— pick a driver —</option>
@foreach (var (id, display, _) in Drivers) @foreach (var d in Drivers)
{ {
<option value="@id">@display</option> <option value="@d.Id">@d.Display</option>
} }
</InputSelect> </InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" /> <ValidationMessage For="@(() => _form.DriverInstanceId)" />
@@ -90,6 +93,35 @@
{ {
<div class="form-text">Pick a driver above to configure this tag.</div> <div class="form-text">Pick a driver above to configure this tag.</div>
} }
else if (IsGalaxyDriver)
{
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="@(() => _showGalaxyPicker = true)">
Browse Galaxy
</button>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
<div class="form-text">
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
</div>
@if (_showGalaxyPicker)
{
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
Title="Galaxy address"
CurrentAddress="@_galaxyAddress"
OnPickAddress="@OnGalaxyAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
GetConfigJson="@(() => SelectedDriverConfig)" />
</DriverTagPicker>
}
}
else if (editorType is not null) else if (editorType is not null)
{ {
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" /> <DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
@@ -139,8 +171,10 @@
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary> /// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; } [Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary> /// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType)> Drivers { get; set; } = Array.Empty<(string, string, string)>(); /// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary> /// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; } [Parameter] public EventCallback OnSaved { get; set; }
@@ -152,14 +186,47 @@
private bool _busy; private bool _busy;
private string? _error; private string? _error;
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen. // The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType => private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType; Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
private string SelectedDriverConfig
{
get
{
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
}
}
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies — // When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old // reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load. // driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
private void OnDriverChanged() => _form.TagConfig = "{}"; private void OnDriverChanged()
{
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
private void OnGalaxyAddressPicked(string address)
{
_galaxyAddress = address;
_form.TagConfig = JsonSerializer.Serialize(new { FullName = address });
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object> private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{ {
@@ -189,6 +256,28 @@
}; };
} }
_error = null; _error = null;
_showGalaxyPicker = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
private static string ReadFullName(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return "";
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == JsonValueKind.String
? fn.GetString() ?? ""
: "";
}
catch (JsonException)
{
return "";
}
} }
private async Task SaveAsync() private async Task SaveAsync()
@@ -340,9 +340,11 @@ public interface IUnsTreeService
/// </summary> /// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param> /// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param> /// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType)</c> triples, /// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType, DriverConfig)</c>
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns> /// tuples, where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default); /// and <c>DriverConfig</c> feeds the live-browse address picker (e.g. the Galaxy gateway picker opens its
/// browse session against the selected GalaxyMxGateway's config).</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary> /// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 — /// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
@@ -711,7 +711,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync( public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
string equipmentId, string equipmentId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@@ -720,10 +720,12 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct); var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null) if (equipmentCluster is null)
{ {
return Array.Empty<(string, string, string)>(); return Array.Empty<(string, string, string, string)>();
} }
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110). // Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
// GalaxyMxGateway is an ordinary Equipment-kind driver post-de-split, so it surfaces here
// alongside the PLC drivers; its DriverConfig feeds the Galaxy live-browse address picker.
var equipmentNamespaceIds = await db.Namespaces var equipmentNamespaceIds = await db.Namespaces
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment) .Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
.Select(n => n.NamespaceId) .Select(n => n.NamespaceId)
@@ -732,11 +734,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var drivers = await db.DriverInstances var drivers = await db.DriverInstances
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId)) .Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
.OrderBy(d => d.DriverInstanceId) .OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType }) .Select(d => new { d.DriverInstanceId, d.Name, d.DriverType, d.DriverConfig })
.ToListAsync(ct); .ToListAsync(ct);
return drivers return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType)) .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType, d.DriverConfig))
.ToList(); .ToList();
} }
@@ -8,17 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary> /// <summary>
/// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each /// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each
/// candidate driver's <c>DriverType</c> alongside its id and display string, so the UNS TagModal /// candidate driver's <c>DriverType</c> and <c>DriverConfig</c> alongside its id and display string,
/// can later dispatch to a per-driver-type typed tag-config editor (F-uns-1). /// so the UNS TagModal can dispatch to a per-driver-type typed tag-config editor (F-uns-1) and feed
/// the selected gateway's config to the Galaxy live-browse address picker.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class UnsTreeServiceTagDriversTests public sealed class UnsTreeServiceTagDriversTests
{ {
/// <summary> /// <summary>
/// A driver loaded for an equipment carries its <c>DriverType</c> in the returned tuple. /// A driver loaded for an equipment carries its <c>DriverType</c> and <c>DriverConfig</c> in the
/// returned tuple.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task LoadTagDriversForEquipment_surfaces_driver_type() public async Task LoadTagDriversForEquipment_surfaces_driver_type_and_config()
{ {
var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}"; var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}";
@@ -57,7 +59,7 @@ public sealed class UnsTreeServiceTagDriversTests
NamespaceId = "NS-EQ", NamespaceId = "NS-EQ",
Name = "equipment driver", Name = "equipment driver",
DriverType = "ModbusTcp", DriverType = "ModbusTcp",
DriverConfig = "{}", DriverConfig = """{"endpoint":"10.0.0.1:502"}""",
}); });
db.SaveChanges(); db.SaveChanges();
} }
@@ -69,5 +71,7 @@ public sealed class UnsTreeServiceTagDriversTests
drivers.Count.ShouldBe(1); drivers.Count.ShouldBe(1);
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ"); drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
drivers[0].DriverType.ShouldBe("ModbusTcp"); drivers[0].DriverType.ShouldBe("ModbusTcp");
// The picker (Galaxy live-browse) opens its session against the selected driver's config.
drivers[0].DriverConfig.ShouldBe("""{"endpoint":"10.0.0.1:502"}""");
} }
} }