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:
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Sql:
|
||||
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
|
||||
+1
@@ -36,6 +36,7 @@
|
||||
<option value="Focas">Focas</option>
|
||||
<option value="OpcUaClient">OpcUaClient</option>
|
||||
<option value="GalaxyMxGateway">Galaxy</option>
|
||||
<option value="Sql">Sql</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Cannot be changed after creation — drives the actor type that owns this instance.</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),
|
||||
("OpcUaClient", DriverTypeNames.OpcUaClient),
|
||||
("Galaxy", DriverTypeNames.Galaxy),
|
||||
("Sql", DriverTypeNames.Sql),
|
||||
("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