feat(adminui): author + configure the Sql driver in /raw
The read-only Sql driver's runtime factory/probe and tag-side editors were wired, but the AdminUI could not author the DRIVER: the /raw "New driver" type list, the DriverConfigModal switch, and the legacy identity dropdown all omitted Sql, and no SqlDriverForm existed. Live-driving the AdminUI surfaced this. - Add SqlDriverForm.razor over the SqlDriverConfigDto shape: Provider (SqlServer-only in v1), required connectionStringRef (an env-var NAME, not a connection string — label is explicit), optional poll/operation/command timeouts, maxConcurrentGroups, nullIsBad. Enums serialize by NAME via JsonStringEnumConverter; a connection-string literal can never be authored (no such field is collected); rawTags (composer-owned) + allowWrites (inert, read-only v1) are never emitted. - Wire Sql into DriverConfigModal switch, RawDriverTypeDialog type list, and DriverIdentitySection legacy dropdown. - Add SqlDriverFormContractTests: the exact JSON the form emits constructs a driver through the real factory, missing connectionStringRef is rejected, and provider round-trips as the "SqlServer" name (never an ordinal). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -57,6 +57,9 @@
|
|||||||
case DriverTypeNames.Galaxy:
|
case DriverTypeNames.Galaxy:
|
||||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
break;
|
break;
|
||||||
|
case DriverTypeNames.Sql:
|
||||||
|
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
break;
|
break;
|
||||||
|
|||||||
+1
@@ -36,6 +36,7 @@
|
|||||||
<option value="Focas">Focas</option>
|
<option value="Focas">Focas</option>
|
||||||
<option value="OpcUaClient">OpcUaClient</option>
|
<option value="OpcUaClient">OpcUaClient</option>
|
||||||
<option value="GalaxyMxGateway">Galaxy</option>
|
<option value="GalaxyMxGateway">Galaxy</option>
|
||||||
|
<option value="Sql">Sql</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
@* Embeddable read-only Sql driver (connection/dialect) config form body. There is NO per-device
|
||||||
|
endpoint — the whole database connection is named indirectly by ConnectionStringRef (an env-var name,
|
||||||
|
resolved at Initialize), so no connection string is ever authored into the config. Hosted by
|
||||||
|
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save time and fires
|
||||||
|
DriverConfigJsonChanged for @bind support. Enums serialize by NAME (JsonStringEnumConverter) so the
|
||||||
|
factory's string-typed deserialize accepts them. *@
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Text.Json.Nodes
|
||||||
|
@using System.Text.Json.Serialization
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts
|
||||||
|
|
||||||
|
@* Connection *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||||
|
<div class="panel-head">Database connection</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Provider</label>
|
||||||
|
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||||
|
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
|
||||||
|
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
|
||||||
|
</InputSelect>
|
||||||
|
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
|
||||||
|
<InputText @bind-Value="_form.ConnectionStringRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||||
|
placeholder="MesStaging" />
|
||||||
|
<div class="form-text">
|
||||||
|
Name of the <code>Sql__ConnectionStrings__<ref></code> env var set on the driver + AdminUI hosts —
|
||||||
|
<strong>not</strong> a connection string. The credential is resolved at startup and never stored in the config.
|
||||||
|
</div>
|
||||||
|
@if (string.IsNullOrWhiteSpace(_form.ConnectionStringRef))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1">Connection string ref is required.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Polling / timeouts *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||||
|
<div class="panel-head">Polling & timeouts</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Default poll interval (s)</label>
|
||||||
|
<InputNumber @bind-Value="_form.DefaultPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Blank = factory default. Applied to groups without their own interval.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Operation timeout (s)</label>
|
||||||
|
<InputNumber @bind-Value="_form.OperationTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Client-side per-query deadline. Should exceed the command timeout.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Command timeout (s)</label>
|
||||||
|
<InputNumber @bind-Value="_form.CommandTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">ADO.NET server-side CommandTimeout backstop.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Max concurrent groups</label>
|
||||||
|
<InputNumber @bind-Value="_form.MaxConcurrentGroups" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||||
|
<div class="form-text">Blank = factory default. Caps concurrent group queries (pool guard).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* Value handling *@
|
||||||
|
<section class="panel rise mt-3" style="animation-delay:.10s">
|
||||||
|
<div class="panel-head">Value handling</div>
|
||||||
|
<div style="padding:1rem">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<InputCheckbox @bind-Value="_form.NullIsBad" @bind-Value:after="EmitAsync" class="form-check-input" id="sqlNullIsBad" />
|
||||||
|
<label class="form-check-label" for="sqlNullIsBad">NULL cell publishes Bad</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-0">Unchecked = factory default (a NULL cell publishes Uncertain).</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="sqlAllowWrites" disabled checked="false" />
|
||||||
|
<label class="form-check-label" for="sqlAllowWrites">Allow writes</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text mt-0">
|
||||||
|
<span class="badge bg-secondary">Read-only</span> v1 does not implement writes — this flag is inert and is not authored.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The driver-level DriverConfig JSON (connection/dialect; there is no device endpoint).</summary>
|
||||||
|
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||||
|
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||||
|
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||||
|
[Parameter] public string? ResilienceConfig { get; set; }
|
||||||
|
/// <summary>Fired when the resilience overrides change.</summary>
|
||||||
|
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||||
|
|
||||||
|
// camelCase + string enums + omit-nulls, matching the factory's JsonStringEnumConverter deserialize.
|
||||||
|
// WhenWritingNull keeps absent optionals out of the blob (absent ⇒ the factory applies its default) and
|
||||||
|
// drops the composer-owned rawTags (never authored here).
|
||||||
|
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private FormModel _form = new();
|
||||||
|
private string? _lastParsed;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
|
||||||
|
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var dto = TryDeserialize(DriverConfigJson) ?? new SqlDriverConfigDto();
|
||||||
|
_form = FormModel.FromDto(dto);
|
||||||
|
_lastParsed = DriverConfigJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serializes the current connection/dialect config to camelCase JSON with enums as NAME strings.
|
||||||
|
/// rawTags is never emitted (the composer adds it at deploy); allowWrites is never emitted (v1 read-only).</summary>
|
||||||
|
public string GetConfigJson() => JsonSerializer.Serialize(_form.ToDto(), _jsonOpts);
|
||||||
|
|
||||||
|
private async Task EmitAsync()
|
||||||
|
{
|
||||||
|
var json = GetConfigJson();
|
||||||
|
_lastParsed = json;
|
||||||
|
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnResilienceChanged(string? r)
|
||||||
|
{
|
||||||
|
ResilienceConfig = r;
|
||||||
|
await ResilienceConfigChanged.InvokeAsync(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SqlDriverConfigDto? TryDeserialize(string json)
|
||||||
|
{
|
||||||
|
try { return JsonSerializer.Deserialize<SqlDriverConfigDto>(json, _jsonOpts); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flat mutable mirror of the driver-level fields of <see cref="SqlDriverConfigDto"/>. TimeSpans are
|
||||||
|
/// edited as nullable whole-second ints (blank ⇒ null ⇒ the factory default). rawTags + allowWrites are not
|
||||||
|
/// mirrored — the composer owns rawTags and v1 is read-only.</summary>
|
||||||
|
public sealed class FormModel
|
||||||
|
{
|
||||||
|
public SqlProvider Provider { get; set; } = SqlProvider.SqlServer;
|
||||||
|
public string? ConnectionStringRef { get; set; }
|
||||||
|
public int? DefaultPollIntervalSeconds { get; set; }
|
||||||
|
public int? OperationTimeoutSeconds { get; set; }
|
||||||
|
public int? CommandTimeoutSeconds { get; set; }
|
||||||
|
public int? MaxConcurrentGroups { get; set; }
|
||||||
|
public bool NullIsBad { get; set; }
|
||||||
|
|
||||||
|
public static FormModel FromDto(SqlDriverConfigDto d) => new()
|
||||||
|
{
|
||||||
|
Provider = d.Provider,
|
||||||
|
ConnectionStringRef = d.ConnectionStringRef,
|
||||||
|
DefaultPollIntervalSeconds = ToSeconds(d.DefaultPollInterval),
|
||||||
|
OperationTimeoutSeconds = ToSeconds(d.OperationTimeout),
|
||||||
|
CommandTimeoutSeconds = ToSeconds(d.CommandTimeout),
|
||||||
|
MaxConcurrentGroups = d.MaxConcurrentGroups,
|
||||||
|
NullIsBad = d.NullIsBad ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public SqlDriverConfigDto ToDto() => new()
|
||||||
|
{
|
||||||
|
Provider = Provider,
|
||||||
|
ConnectionStringRef = string.IsNullOrWhiteSpace(ConnectionStringRef) ? null : ConnectionStringRef.Trim(),
|
||||||
|
DefaultPollInterval = ToTimeSpan(DefaultPollIntervalSeconds),
|
||||||
|
OperationTimeout = ToTimeSpan(OperationTimeoutSeconds),
|
||||||
|
CommandTimeout = ToTimeSpan(CommandTimeoutSeconds),
|
||||||
|
MaxConcurrentGroups = MaxConcurrentGroups,
|
||||||
|
// Emit nullIsBad only when true; unchecked ⇒ null ⇒ the factory default (Uncertain). allowWrites
|
||||||
|
// and rawTags are deliberately left null so they are omitted from the authored blob.
|
||||||
|
NullIsBad = NullIsBad ? true : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int? ToSeconds(TimeSpan? ts) => ts.HasValue ? (int)ts.Value.TotalSeconds : null;
|
||||||
|
private static TimeSpan? ToTimeSpan(int? secs) => secs.HasValue ? TimeSpan.FromSeconds(secs.Value) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
("FOCAS", DriverTypeNames.FOCAS),
|
("FOCAS", DriverTypeNames.FOCAS),
|
||||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||||
("Galaxy", DriverTypeNames.Galaxy),
|
("Galaxy", DriverTypeNames.Galaxy),
|
||||||
|
("Sql", DriverTypeNames.Sql),
|
||||||
("Calculation", "Calculation"),
|
("Calculation", "Calculation"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the AdminUI <c>SqlDriverForm</c>'s driver-config output against the runtime factory that consumes
|
||||||
|
/// it. The form is razor (no bUnit here), so these tests pin two things the live verify then confirms:
|
||||||
|
/// (1) the exact JSON bytes the form emits for a minimal + fuller config are accepted by the authoritative
|
||||||
|
/// reader, <see cref="SqlDriverFactoryExtensions.CreateInstance(string,string)"/>; and (2) the
|
||||||
|
/// serialization contract — <c>provider</c> as a NAME string (never an ordinal), camelCase keys, and no
|
||||||
|
/// composer-owned <c>rawTags</c> / inert <c>allowWrites</c> leaking into the authored blob. A numeric enum
|
||||||
|
/// or a stray connection string is the "authors fine, never reads / leaks a secret" defect class this guards.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlDriverFormContractTests
|
||||||
|
{
|
||||||
|
// The JsonSerializerOptions SqlDriverForm serializes with (camelCase + string enums + omit-nulls).
|
||||||
|
private static readonly JsonSerializerOptions FormOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
WriteIndented = false,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- LOAD-BEARING: the exact bytes SqlDriverForm emits must construct a driver through the factory ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Minimal valid config: provider + required connectionStringRef only (all timeouts left to the factory).
|
||||||
|
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest"}""")]
|
||||||
|
// Fuller config with every optional the form authors (timeouts satisfy operationTimeout > commandTimeout).
|
||||||
|
[InlineData("""{"provider":"SqlServer","connectionStringRef":"AdminUiFormTest","defaultPollInterval":"00:00:05","operationTimeout":"00:00:20","commandTimeout":"00:00:10","maxConcurrentGroups":8,"nullIsBad":true}""")]
|
||||||
|
public void FormEmittedJson_constructs_a_driver_through_the_factory(string formEmittedJson)
|
||||||
|
{
|
||||||
|
var envVar = SqlConnectionStringResolver.EnvironmentVariableFor("AdminUiFormTest");
|
||||||
|
var previous = Environment.GetEnvironmentVariable(envVar);
|
||||||
|
Environment.SetEnvironmentVariable(envVar, "Server=localhost;Database=Test;Trusted_Connection=True;");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var driver = SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", formEmittedJson);
|
||||||
|
driver.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(envVar, previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Factory_rejects_a_blob_missing_connectionStringRef()
|
||||||
|
{
|
||||||
|
// The form marks connectionStringRef required; the factory is the authoritative enforcement.
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(
|
||||||
|
() => SqlDriverFactoryExtensions.CreateInstance("adminui-form-test", """{"provider":"SqlServer"}"""));
|
||||||
|
ex.Message.ShouldContain("connectionStringRef");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- serialization contract: enum-by-name, camelCase, no rawTags / allowWrites / null optionals --------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Minimal_config_serializes_provider_as_a_name_and_omits_owned_and_null_fields()
|
||||||
|
{
|
||||||
|
// Mirrors SqlDriverForm.FormModel.ToDto() for a minimal config (only the required ref set).
|
||||||
|
var dto = new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "MesStaging" };
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(dto, FormOptions);
|
||||||
|
|
||||||
|
json.ShouldBe("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""");
|
||||||
|
json.ShouldNotContain("\"provider\":0"); // never an ordinal — that faults the string-typed factory read
|
||||||
|
json.ShouldNotContain("rawTags"); // composer-owned; never authored by the form
|
||||||
|
json.ShouldNotContain("allowWrites"); // inert in v1 (read-only); never authored
|
||||||
|
json.ShouldNotContain("defaultPollInterval"); // null optional omitted ⇒ factory default applies
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Provider_round_trips_as_the_SqlServer_name()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(
|
||||||
|
new SqlDriverConfigDto { Provider = SqlProvider.SqlServer, ConnectionStringRef = "R" }, FormOptions);
|
||||||
|
|
||||||
|
json.ShouldContain("\"SqlServer\"");
|
||||||
|
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user