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:
Joseph Doherty
2026-07-24 20:41:33 -04:00
parent 585f827ef3
commit b295b24419
5 changed files with 292 additions and 0 deletions
@@ -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;
@@ -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>
@@ -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__&lt;ref&gt;</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 &amp; 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"),
];