feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:58:13 -04:00
parent 928e06dd01
commit 76cffe1f49
4 changed files with 1033 additions and 0 deletions
@@ -29,6 +29,48 @@ public readonly record struct RawTagEditDto(
string TagConfig,
byte[] RowVersion);
/// <summary>
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the Wave-B (WP3)
/// "Configure driver" modal. <c>DriverType</c> is included so the modal can dispatch to the correct
/// typed driver-config editor; it is immutable, so <see cref="IRawTreeService.UpdateDriverAsync"/> does
/// not change it.
/// </summary>
/// <param name="DriverInstanceId">The driver instance's stable logical id.</param>
/// <param name="Name">The driver instance name (a RawPath segment).</param>
/// <param name="DriverType">The immutable driver-type string (see <c>DriverTypeNames</c>).</param>
/// <param name="DriverConfig">The schemaless per-driver-type <c>DriverConfig</c> JSON blob.</param>
/// <param name="ResilienceConfig">Optional per-instance resilience-pipeline overrides JSON, or null.</param>
/// <param name="RowVersion">The optimistic-concurrency token.</param>
public sealed record RawDriverEditDto(
string DriverInstanceId,
string Name,
string DriverType,
string DriverConfig,
string? ResilienceConfig,
byte[] RowVersion);
/// <summary>
/// Detached, editable projection of a <c>Device</c> — the read side of the Wave-B (WP3) "Configure
/// device" modal. <c>DriverType</c> is joined in from the parent <c>DriverInstance</c> so the modal can
/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in
/// <c>DeviceConfig</c>).
/// </summary>
/// <param name="DeviceId">The device's stable logical id.</param>
/// <param name="DriverInstanceId">The owning driver instance's logical id.</param>
/// <param name="Name">The device name (a RawPath segment).</param>
/// <param name="DeviceConfig">The schemaless per-driver-type <c>DeviceConfig</c> JSON blob (host/endpoint + per-device settings).</param>
/// <param name="Enabled">Whether the device is enabled.</param>
/// <param name="DriverType">The parent driver's type string, joined in for editor dispatch.</param>
/// <param name="RowVersion">The optimistic-concurrency token.</param>
public sealed record RawDeviceEditDto(
string DeviceId,
string DriverInstanceId,
string Name,
string DeviceConfig,
bool Enabled,
string DriverType,
byte[] RowVersion);
/// <summary>
/// Loads and mutates the v3 Raw project tree (<c>/raw</c>) — the cluster-rooted
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
@@ -251,4 +293,105 @@ public interface IRawTreeService
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The tag's editable projection, or null when the tag does not exist.</returns>
Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Creates a raw <c>Tag</c> under a device (<paramref name="tagGroupId"/> null) or a tag group. The
/// name is validated as a RawPath segment and must be unique among its siblings on the same
/// <c>(DeviceId, TagGroupId)</c>; the <see cref="RawTagInput.TagConfig"/> is validated for JSON
/// well-formedness. On success the result's <c>CreatedId</c> carries the new tag's logical id.
/// </summary>
/// <param name="deviceId">The owning device's logical id.</param>
/// <param name="tagGroupId">The containing tag group's logical id, or null for a device-root tag.</param>
/// <param name="input">The tag's editable fields (identity is the RawPath — no TagId here).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagId</c> on success.</returns>
Task<UnsMutationResult> CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates a raw <c>Tag</c>'s editable fields. The new name is re-validated as a RawPath segment and
/// sibling-unique on the tag's existing <c>(DeviceId, TagGroupId)</c> excluding itself; the
/// <see cref="RawTagInput.TagConfig"/> is validated for JSON well-formedness. Last-write-wins on
/// <paramref name="rowVersion"/>. The tag's <c>DeviceId</c>/<c>TagGroupId</c> (its identity location)
/// are preserved — moving a tag is not this method's job.
/// </summary>
/// <param name="tagId">The tag's logical id.</param>
/// <param name="input">The tag's edited fields.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded tag.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Commits a batch of raw tags under a device — the WP5 CSV-import path. Each row's
/// <c>TagGroupPath</c> is a <c>/</c>-separated tag-group path under the device whose segments are
/// auto-created as nested <c>TagGroup</c>s (reusing existing ones). Per-row validation errors (bad
/// name segment, malformed <c>TagConfig</c> JSON, a duplicate name within the device+group) are
/// collected, not thrown. The import is <b>all-or-nothing</b>: if any row errors, nothing is inserted
/// and the errors are returned; otherwise all tags (and any newly-needed groups) are committed in one
/// <c>SaveChanges</c>.
/// </summary>
/// <param name="deviceId">The target device's logical id.</param>
/// <param name="rows">The rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The import outcome: the inserted count on success, or the per-row errors with a 0 count.</returns>
Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default);
/// <summary>
/// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver"
/// modal. <c>DriverType</c> is included for typed-editor dispatch (it is immutable).
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The driver's editable projection, or null when it does not exist.</returns>
Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default);
/// <summary>
/// Updates a driver instance's name + config from the WP3 "Configure driver" modal. The name is
/// validated as a sibling-unique RawPath segment (same scope as <see cref="RenameDriverAsync"/>);
/// last-write-wins on <paramref name="rowVersion"/>. <c>DriverType</c> is immutable and is not changed.
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="name">The (possibly changed) driver name.</param>
/// <param name="driverConfigJson">The new <c>DriverConfig</c> JSON blob.</param>
/// <param name="resilienceConfig">The new resilience-config JSON, or null/blank to clear.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal.
/// <c>DriverType</c> is joined in from the parent driver so the modal can dispatch to the correct typed
/// device-config editor.
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The device's editable projection, or null when it does not exist.</returns>
Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default);
/// <summary>
/// Updates a device's name + config + enabled state from the WP3 "Configure device" modal. The name
/// is validated as a RawPath segment unique among the driver's devices; last-write-wins on
/// <paramref name="rowVersion"/>.
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="name">The (possibly changed) device name.</param>
/// <param name="deviceConfigJson">The new <c>DeviceConfig</c> JSON blob (endpoint + per-device settings).</param>
/// <param name="enabled">The new enabled state.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device:
/// the parent driver's <c>DriverConfig</c> folded together with this device's <c>DeviceConfig</c> (via
/// <c>DriverDeviceConfigMerger</c>, endpoint keys merging up from <c>DeviceConfig</c>). Used by the
/// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in
/// <c>DeviceConfig</c>.
/// </summary>
/// <param name="deviceId">The device to probe.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved.</returns>
Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default);
}