feat(sql): AdminUI browser DI + Sql address picker body

Register SqlDriverBrowser as an IDriverBrowser beside the OpcUaClient/Galaxy
lines, project-reference Driver.Sql.Browser, and add SqlAddressPickerBody.razor:
a DriverBrowseTree schema/table/column picker (DriverOperator-gated) with a
column attribute side-panel, manual-entry model/selector fields retained, that
composes the per-tag TagConfig blob (KeyValue / WideRow) matched to
SqlEquipmentTagParser. Pasted ad-hoc connection strings are session-only and
never persisted into the composed blob or driver config.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 15:11:56 -04:00
parent 4c716242ac
commit 14fe89c505
3 changed files with 451 additions and 0 deletions
@@ -0,0 +1,441 @@
@* Sql address picker (read-only v1):
1. Manual entry (ALWAYS available): pick the model (KeyValue / WideRow), then fill the
table + key/row-selector fields by hand. An operator without DriverOperator, or browsing a
server the AdminUI can't reach, authors a tag entirely from these fields.
2. (DriverOperator-gated) Live schema browse: schema → table → column tree on the left, the
picked column's type in the attribute side-panel on the right. Clicking a column leaf prefills
`table`, the value/column field, and the inferred `type`.
The picker's OUTPUT (CurrentAddress) is the composed per-tag `TagConfig` JSON blob — exactly the
shape SqlEquipmentTagParser.TryParse reads (design §5.2 / §5.3). Field names + the string `model`/
`type` enums are matched to the parser precisely; a mismatch would author fine and never read.
Credential hygiene: the optional ad-hoc connection string is SESSION-ONLY. It is merged into the
JSON handed to BrowserService.OpenAsync for THIS browse only and is NEVER written into the composed
TagConfig blob nor back into the driver config — there is no code path here that persists it. *@
@implements IAsyncDisposable
@using System.Text.Json.Nodes
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Sql
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser
@inject IBrowserSessionService BrowserService
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Model</label>
<select class="form-select form-select-sm" @bind="_model" @bind:after="OnManualChangedAsync">
<option value="KeyValue">KeyValue</option>
<option value="WideRow">WideRow</option>
</select>
<div class="form-text">
KeyValue: one row per key. WideRow: many columns of one selected row.
</div>
</div>
<div class="col-md-5">
<label class="form-label">Table or view</label>
<input type="text" class="form-control form-control-sm mono" placeholder="dbo.TagValues"
@bind="_table" @bind:after="OnManualChangedAsync" />
<div class="form-text">Schema-qualified, e.g. <code>dbo.TagValues</code>.</div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select class="form-select form-select-sm" @bind="_type" @bind:after="OnManualChangedAsync">
<option value="">(infer)</option>
@foreach (var t in Enum.GetValues<DriverDataType>())
{
<option value="@t">@t</option>
}
</select>
<div class="form-text">Blank ⇒ inferred from the column.</div>
</div>
</div>
@if (_model == "KeyValue")
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Key column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="tag_name"
@bind="_keyColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-4">
<label class="form-label">Key value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="Line1.Speed"
@bind="_keyValue" @bind:after="OnManualChangedAsync" />
<div class="form-text">Bound as a parameter — never interpolated.</div>
</div>
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="num_value"
@bind="_valueColumn" @bind:after="OnManualChangedAsync" />
</div>
</div>
}
else
{
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Value column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="oven_temp"
@bind="_columnName" @bind:after="OnManualChangedAsync" />
<div class="form-text">The column this tag reads from the selected row.</div>
</div>
<div class="col-md-4">
<label class="form-label">Row selector</label>
<select class="form-select form-select-sm" @bind="_selectorMode" @bind:after="OnManualChangedAsync">
<option value="Where">Where column = value</option>
<option value="Newest">Newest by timestamp</option>
</select>
</div>
@if (_selectorMode == "Where")
{
<div class="col-md-2">
<label class="form-label">Where column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="station_id"
@bind="_whereColumn" @bind:after="OnManualChangedAsync" />
</div>
<div class="col-md-2">
<label class="form-label">Where value</label>
<input type="text" class="form-control form-control-sm mono" placeholder="7"
@bind="_whereValue" @bind:after="OnManualChangedAsync" />
</div>
}
else
{
<div class="col-md-4">
<label class="form-label">Order-by (timestamp) column</label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_topByTimestamp" @bind:after="OnManualChangedAsync" />
</div>
}
</div>
}
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Timestamp column <span class="text-muted small">(optional)</span></label>
<input type="text" class="form-control form-control-sm mono" placeholder="sample_ts"
@bind="_timestampColumn" @bind:after="OnManualChangedAsync" />
<div class="form-text">Source-timestamp column; blank ⇒ the driver stamps the poll time.</div>
</div>
</div>
@if (_canOperate)
{
<hr class="my-3" />
<div class="row g-3">
<div class="col-md-12">
<label class="form-label small">Ad-hoc connection string <span class="text-muted">(session-only, never saved)</span></label>
<input type="password" class="form-control form-control-sm mono" autocomplete="off"
placeholder="Server=…;Database=…;User Id=…;Password=…"
@bind="_adhocConnectionString" />
<div class="form-text">
Leave blank to browse via the driver's <code>connectionStringRef</code> (resolved from
the AdminUI host's <code>Sql__ConnectionStrings__&lt;ref&gt;</code> env var). Paste a
literal only for a one-off browse — it is used for this session and never persisted.
</div>
</div>
</div>
<div class="mt-2 d-flex align-items-center gap-2">
@if (_token == Guid.Empty)
{
<button type="button" class="btn btn-outline-primary btn-sm" disabled="@_opening"
@onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Browse database
</button>
}
else
{
<span class="chip chip-ok">Browser open</span>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseBrowseAsync">Close</button>
}
</div>
@if (_openError is not null)
{
<div class="text-danger small mt-2">@_openError</div>
}
@if (_token != Guid.Empty)
{
<div class="row g-3 mt-1">
<div class="col-md-7">
<label class="form-label small">Schemas → tables → columns</label>
<DriverBrowseTree SessionToken="_token" OnNodeSelected="OnTreeSelectAsync"
SelectedNodeId="@_selectedColumnNodeId" />
</div>
<div class="col-md-5">
<label class="form-label small">Picked column</label>
<div class="border rounded p-2" style="max-height:420px; overflow:auto; min-height:240px">
@if (_attrsLoading)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else if (_attrsError is not null)
{
<span class="text-danger small">@_attrsError</span>
}
else if (_attrs is null)
{
<span class="text-muted small">Pick a column leaf.</span>
}
else if (_attrs.Count == 0)
{
<span class="text-muted small">Column no longer present.</span>
}
else
{
@foreach (var a in _attrs)
{
<div class="d-flex justify-content-between align-items-center py-1"
style="cursor:pointer" @onclick="@(() => ApplyAttributeAsync(a))">
<span class="mono small">@a.Name</span>
<span class="text-muted small">@a.DriverDataType · @a.SecurityClass</span>
</div>
}
}
</div>
</div>
</div>
}
}
<div class="mt-3">
<span class="text-muted small">TagConfig:</span>
<code class="mono ms-2">@(_built.Length == 0 ? "—" : _built)</code>
</div>
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback<string> CurrentAddressChanged { get; set; }
/// <summary>Live accessor for the selected driver's <c>DriverConfig</c> JSON (carries
/// <c>connectionStringRef</c> + <c>provider</c>). Fed to <c>BrowserService.OpenAsync("Sql", …)</c>.</summary>
[Parameter, EditorRequired] public Func<string> GetConfigJson { get; set; } = () => "{}";
/// <summary>Fires with the picker's suggested tag display name (<c>table.column</c>) whenever a
/// column is picked from the tree, so a host editor may default the raw tag's name. Optional —
/// the composed TagConfig blob itself carries no display name (it is not a parser field).</summary>
[Parameter] public EventCallback<string> SuggestedDisplayNameChanged { get; set; }
// Manual/composed tag fields (all editable by hand; browse only prefills a subset).
private string _model = "KeyValue";
private string _table = "";
private string _keyColumn = "";
private string _keyValue = "";
private string _valueColumn = "";
private string _columnName = "";
private string _selectorMode = "Where";
private string _whereColumn = "";
private string _whereValue = "";
private string _topByTimestamp = "";
private string _timestampColumn = "";
private string _type = "";
// Browse-only, session-scoped state.
private string _adhocConnectionString = "";
private string _built = "";
private string _selectedColumnNodeId = "";
private Guid _token = Guid.Empty;
private bool _opening;
private bool _canOperate;
private string? _openError;
private bool _attrsLoading;
private string? _attrsError;
private IReadOnlyList<AttributeInfo>? _attrs;
protected override async Task OnInitializedAsync()
{
var auth = await AuthState.GetAuthenticationStateAsync();
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
_canOperate = authResult.Succeeded;
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OpenBrowseAsync()
{
_opening = true;
_openError = null;
StateHasChanged();
try
{
// Merge the ad-hoc literal into the OpenAsync config for THIS session only. The literal is
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
finally { _opening = false; StateHasChanged(); }
}
/// <summary>
/// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one,
/// a top-level <c>connectionString</c> literal. The literal wins over any <c>connectionStringRef</c>
/// server-side (SqlDriverBrowser precedence) and is session-only.
/// </summary>
private string BuildBrowseConfig()
{
var baseJson = GetConfigJson() ?? "{}";
if (string.IsNullOrWhiteSpace(_adhocConnectionString)) { return baseJson; }
JsonObject o;
try { o = JsonNode.Parse(baseJson) as JsonObject ?? new JsonObject(); }
catch (System.Text.Json.JsonException) { o = new JsonObject(); }
o["connectionString"] = _adhocConnectionString;
return o.ToJsonString();
}
private async Task CloseBrowseAsync()
{
var t = _token;
_token = Guid.Empty;
_attrs = null;
_selectedColumnNodeId = "";
StateHasChanged();
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
}
/// <summary>
/// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are
/// navigation, not selection. The NodeId is decoded with <see cref="SqlBrowseNodeId.TryParse"/>
/// — never split by hand, because SQL identifiers may contain '.' and '|'.
/// </summary>
private async Task OnTreeSelectAsync(BrowseNode node)
{
if (!SqlBrowseNodeId.TryParse(node.NodeId, out var reference)
|| reference.Kind != SqlBrowseNodeKind.Column)
{
return;
}
_selectedColumnNodeId = node.NodeId;
_table = $"{reference.Schema}.{reference.Table}";
if (_model == "WideRow") { _columnName = reference.Column!; }
else { _valueColumn = reference.Column!; }
// Suggested display name uses the BARE table name (the blob's `table` stays schema-qualified).
await SuggestedDisplayNameChanged.InvokeAsync($"{reference.Table}.{reference.Column}");
_attrs = null;
_attrsLoading = true;
_attrsError = null;
StateHasChanged();
try
{
_attrs = await BrowserService.AttributesAsync(_token, node.NodeId, default);
var picked = _attrs.Count > 0 ? _attrs[0] : null;
if (picked is not null) { PrefillType(picked); }
}
catch (Exception ex) { _attrsError = ex.Message; }
finally
{
_attrsLoading = false;
await OnChangedAsync();
}
}
/// <summary>Re-applies the side-panel column's inferred type (and value-column) on click.</summary>
private async Task ApplyAttributeAsync(AttributeInfo a)
{
if (_model == "WideRow") { _columnName = a.Name; }
else { _valueColumn = a.Name; }
PrefillType(a);
await OnChangedAsync();
}
/// <summary>Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps.</summary>
private void PrefillType(AttributeInfo a)
{
if (Enum.TryParse<DriverDataType>(a.DriverDataType, out _)) { _type = a.DriverDataType; }
}
private async Task OnManualChangedAsync() => await OnChangedAsync();
private async Task OnChangedAsync()
{
_built = Build();
await CurrentAddressChanged.InvokeAsync(_built);
}
/// <summary>
/// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to
/// <c>SqlEquipmentTagParser</c>. Returns "" until the selected model's required fields are all
/// present, so the "Use this address" button never commits a half-blob the parser would reject.
/// <c>model</c> and <c>type</c> are written as NAME strings (the parser reads them strictly).
/// </summary>
private string Build()
{
var table = _table.Trim();
if (table.Length == 0) { return ""; }
var o = new JsonObject
{
["driver"] = "Sql",
["model"] = _model,
["table"] = table,
};
if (_model == "KeyValue")
{
var keyColumn = _keyColumn.Trim();
var valueColumn = _valueColumn.Trim();
var keyValue = _keyValue.Trim();
if (keyColumn.Length == 0 || valueColumn.Length == 0 || keyValue.Length == 0) { return ""; }
o["keyColumn"] = keyColumn;
o["keyValue"] = keyValue;
o["valueColumn"] = valueColumn;
}
else // WideRow
{
var columnName = _columnName.Trim();
if (columnName.Length == 0) { return ""; }
o["columnName"] = columnName;
var rowSelector = new JsonObject();
if (_selectorMode == "Where")
{
var whereColumn = _whereColumn.Trim();
var whereValue = _whereValue.Trim();
if (whereColumn.Length == 0 || whereValue.Length == 0) { return ""; }
rowSelector["whereColumn"] = whereColumn;
rowSelector["whereValue"] = whereValue;
}
else // Newest
{
var topByTimestamp = _topByTimestamp.Trim();
if (topByTimestamp.Length == 0) { return ""; }
rowSelector["topByTimestamp"] = topByTimestamp;
}
o["rowSelector"] = rowSelector;
}
var timestampColumn = _timestampColumn.Trim();
if (timestampColumn.Length > 0) { o["timestampColumn"] = timestampColumn; }
if (_type.Length > 0) { o["type"] = _type; }
// v1 is read-only; every SQL tag is non-writable (SecurityClass = ViewOnly server-side).
o["writable"] = false;
return o.ToJsonString();
}
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow database.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
}
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
@@ -74,6 +75,11 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
// ctor param has a production default. The universal-browser DI line is deliberately NOT added
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
@@ -39,6 +39,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
</ItemGroup>
</Project>