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"),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user