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
+
+
+
+
Model
+
+ KeyValue
+ WideRow
+
+
+ KeyValue: one row per key. WideRow: many columns of one selected row.
+
+
+
+
Table or view
+
+
Schema-qualified, e.g. dbo.TagValues.
+
+
+
Type
+
+ (infer)
+ @foreach (var t in Enum.GetValues())
+ {
+ @t
+ }
+
+
Blank ⇒ inferred from the column.
+
+
+
+@if (_model == "KeyValue")
+{
+
+
+ Key column
+
+
+
+
Key value
+
+
Bound as a parameter — never interpolated.
+
+
+ Value column
+
+
+
+}
+else
+{
+
+
+
Value column
+
+
The column this tag reads from the selected row.
+
+
+ Row selector
+
+ Where column = value
+ Newest by timestamp
+
+
+ @if (_selectorMode == "Where")
+ {
+
+ Where column
+
+
+
+ Where value
+
+
+ }
+ else
+ {
+
+ Order-by (timestamp) column
+
+
+ }
+
+}
+
+
+
+
Timestamp column (optional)
+
+
Source-timestamp column; blank ⇒ the driver stamps the poll time.
+
+
+
+@if (_canOperate)
+{
+
+
+
+
Ad-hoc connection string (session-only, never saved)
+
+
+ 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)
+ {
+
+ @if (_opening) { }
+ Browse database
+
+ }
+ else
+ {
+ Browser open
+ Close
+ }
+
+ @if (_openError is not null)
+ {
+ @_openError
+ }
+
+ @if (_token != Guid.Empty)
+ {
+
+
+ Schemas → tables → columns
+
+
+
+
Picked column
+
+ @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)
+ {
+
ApplyAttributeAsync(a))">
+ @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 @@
+
+