@* 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
KeyValue: one row per key. WideRow: many columns of one selected row.
Schema-qualified, e.g. dbo.TagValues.
Blank ⇒ inferred from the column.
@if (_model == "KeyValue") {
Bound as a parameter — never interpolated.
} else {
The column this tag reads from the selected row.
@if (_selectorMode == "Where") {
} else {
}
}
Source-timestamp column; blank ⇒ the driver stamps the poll time.
@if (_canOperate) {
Leave blank to browse via the driver's connectionStringRef (resolved from the AdminUI host's Sql__ConnectionStrings__<ref> env var). Paste a literal only for a one-off browse — it is used for this session and never persisted.
@if (_token == Guid.Empty) { } else { Browser open }
@if (_openError is not null) {
@_openError
} @if (_token != Guid.Empty) {
@if (_attrsLoading) { } else if (_attrsError is not null) { @_attrsError } else if (_attrs is null) { Pick a column leaf. } else if (_attrs.Count == 0) { Column no longer present. } else { @foreach (var a in _attrs) {
@a.Name @a.DriverDataType · @a.SecurityClass
} }
} }
TagConfig: @(_built.Length == 0 ? "—" : _built)
@code { [Parameter] public string CurrentAddress { get; set; } = ""; [Parameter] public EventCallback CurrentAddressChanged { get; set; } /// Live accessor for the selected driver's DriverConfig JSON (carries /// connectionStringRef + provider). Fed to BrowserService.OpenAsync("Sql", …). [Parameter, EditorRequired] public Func GetConfigJson { get; set; } = () => "{}"; /// Fires with the picker's suggested tag display name (table.column) 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). [Parameter] public EventCallback 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? _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(); } } /// /// Builds the JSON handed to the browser: the driver config plus, when the operator pasted one, /// a top-level connectionString literal. The literal wins over any connectionStringRef /// server-side (SqlDriverBrowser precedence) and is session-only. /// 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); } } /// /// Handles a tree click. Only a COLUMN leaf commits a prefill; schema/table folder clicks are /// navigation, not selection. The NodeId is decoded with /// — never split by hand, because SQL identifiers may contain '.' and '|'. /// 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(); } } /// Re-applies the side-panel column's inferred type (and value-column) on click. private async Task ApplyAttributeAsync(AttributeInfo a) { if (_model == "WideRow") { _columnName = a.Name; } else { _valueColumn = a.Name; } PrefillType(a); await OnChangedAsync(); } /// Prefills the type dropdown from the browsed column's mapped DriverDataType, if it maps. private void PrefillType(AttributeInfo a) { if (Enum.TryParse(a.DriverDataType, out _)) { _type = a.DriverDataType; } } private async Task OnManualChangedAsync() => await OnChangedAsync(); private async Task OnChangedAsync() { _built = Build(); await CurrentAddressChanged.InvokeAsync(_built); } /// /// Composes the per-tag TagConfig blob (design §5.2 / §5.3), matched field-for-field to /// SqlEquipmentTagParser. 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. /// model and type are written as NAME strings (the parser reads them strictly). /// 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; } }