diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor new file mode 100644 index 00000000..b616571a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor @@ -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 + +
+
+ + +
+ 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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 11d629c5..f19cbcf7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -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(); services.AddSingleton(); services.AddSingleton(); + // 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(); // 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). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 10a58694..8e483207 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -39,6 +39,10 @@ + +