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:
+441
@@ -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__<ref></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>
|
||||
|
||||
Reference in New Issue
Block a user