fix(adminui): Heidenhain authoring UX — CSV preview runs the save-path row rule, dictated data type is locked, credential error shown once (#830)
- RawTagRowRules: the ONE driver-dispatched authority on Tag-ROW rules (access level / Tag.DataType). RawTreeService's save path, the CSV import's staged review grid (RawTagCsvMapper) and the manual-entry grid all call it; no call site names a driver. A ReadWrite or mistyped Heidenhain row is now invalid IN THE PREVIEW instead of only at Commit. - Manual-entry grid + tag modal: when the driver dictates the data type for the row's address (RawTagRowDefaults.DictatedDataType), the field is a locked read-only value instead of a select that commit silently replaced; the grid runs the row rule client-side and no longer rewrites the row at commit. - Driver/device modals: the credential refusal is shown once (the modal's save-gate notice); HeidenhainDriverFormModel / HeidenhainDeviceModel.Validate report only ranges / endpoint. - docs/drivers/Heidenhain.md: #831 alarm conditions + rediscovery, #830 authoring behaviour. Tests: HeidenhainAuthoringUxTests (CSV preview == ImportTagsAsync row-for-row, dictated type is what the save path accepts, single credential surface, ALARM: seeding, CSV alarm rows, AlarmMs round-trip); HeidenhainAuthoringTests updated for the single-notice rule.
This commit is contained in:
+6
-4
@@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830):
|
||||
/// the hosting <c>DeviceModal</c> shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which covers every
|
||||
/// password field) once and disables Save — reporting it here too printed the same error twice.
|
||||
/// </summary>
|
||||
public string? Validate()
|
||||
{
|
||||
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
|
||||
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
|
||||
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
|
||||
errors.Add($"{name} password must be blank, secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME> — never a literal.");
|
||||
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port });
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
|
||||
+24
-9
@@ -49,13 +49,23 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@row.DataType"
|
||||
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
@if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated)
|
||||
{
|
||||
@* lmxopcua#830: the driver dictates the type for this address — show it, locked,
|
||||
rather than offer a choice the commit would replace. *@
|
||||
<input class="form-control form-control-sm mono" value="@dictated" disabled readonly
|
||||
title="Set by the address — the driver publishes this type" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" value="@row.DataType"
|
||||
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@row.AccessLevel"
|
||||
@@ -246,7 +256,13 @@
|
||||
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
|
||||
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
|
||||
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
|
||||
if (configError is not null) { row.Error = configError; anyClientError = true; }
|
||||
if (configError is not null) { row.Error = configError; anyClientError = true; continue; }
|
||||
// The dictated fields are locked to the driver's values (applied on every config edit), so this
|
||||
// re-application changes nothing the operator could see; the row rule then runs exactly as the
|
||||
// server's will — a mismatch is SHOWN, never silently rewritten.
|
||||
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
|
||||
var rowError = RawTagRowRules.Problem(DriverType, row.AccessLevel, row.DataType, row.TagConfig);
|
||||
if (rowError is not null) { row.Error = rowError; anyClientError = true; }
|
||||
}
|
||||
if (anyClientError)
|
||||
{
|
||||
@@ -260,7 +276,6 @@
|
||||
var failed = 0;
|
||||
foreach (var row in _rows)
|
||||
{
|
||||
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
|
||||
var input = new RawTagInput(
|
||||
row.Name,
|
||||
row.DataType,
|
||||
|
||||
@@ -46,12 +46,21 @@
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="raw-tag-dtype">Data type</label>
|
||||
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
@if (RawTagRowDefaults.DictatedDataType(DriverType, _form.TagConfig) is { } dictated)
|
||||
{
|
||||
@* lmxopcua#830: locked to what the driver publishes for this address. *@
|
||||
<input id="raw-tag-dtype" class="form-control form-control-sm mono" value="@dictated" disabled readonly />
|
||||
<div class="form-text">Set by the address.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</InputSelect>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -246,6 +246,13 @@ public static class RawTagCsvMapper
|
||||
return Invalid(rowNumber, cells, $"TagConfig: {configError}");
|
||||
}
|
||||
|
||||
// The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit
|
||||
// (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK.
|
||||
if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError)
|
||||
{
|
||||
return Invalid(rowNumber, cells, rowError);
|
||||
}
|
||||
|
||||
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
|
||||
|
||||
return new RawCsvReviewRow
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the
|
||||
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, or the binding's
|
||||
/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the
|
||||
/// server (<c>RawTreeService</c>) and the deploy gate would refuse.
|
||||
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the
|
||||
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, the binding's declared
|
||||
/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both
|
||||
/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have
|
||||
/// one silently replaced on commit. The rules themselves live in <see cref="RawTagRowRules"/>.
|
||||
/// </summary>
|
||||
public static class RawTagRowDefaults
|
||||
{
|
||||
/// <summary>True for a driver whose tags can never be writable.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <returns>Whether the access level is fixed at Read.</returns>
|
||||
public static bool IsReadOnlyDriver(string? driverType) =>
|
||||
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase);
|
||||
public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType);
|
||||
|
||||
/// <summary>The data type the driver dictates for this TagConfig (lock the field to it), or null.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <param name="tagConfig">The row's TagConfig.</param>
|
||||
/// <returns>The dictated type, or null when the operator chooses.</returns>
|
||||
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
|
||||
RawTagRowRules.DictatedDataType(driverType, tagConfig);
|
||||
|
||||
/// <summary>Applies the driver-dictated access level and data type for a row, when the driver dictates them.</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
@@ -25,8 +30,7 @@ public static class RawTagRowDefaults
|
||||
/// <param name="setDataType">Sets the row's <c>Tag.DataType</c>.</param>
|
||||
public static void Apply(string? driverType, string? tagConfig, Action<TagAccessLevel> setAccess, Action<string> setDataType)
|
||||
{
|
||||
if (!IsReadOnlyDriver(driverType)) return;
|
||||
setAccess(TagAccessLevel.Read);
|
||||
if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType);
|
||||
if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read);
|
||||
if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and
|
||||
/// <c>Tag.DataType</c>, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path
|
||||
/// (<see cref="RawTreeService"/> create / update / import), the CSV import's staged review grid
|
||||
/// (<see cref="RawTagCsvMapper"/>), and the manual-entry grid + tag modal (through <see cref="RawTagRowDefaults"/>,
|
||||
/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A driver with row rules registers its functions in <see cref="Rules"/>; the call sites never name a driver.
|
||||
/// The deploy gate applies the same driver functions (e.g. <c>HeidenhainDeployChecks</c> →
|
||||
/// <see cref="HeidenhainTagRules"/>), so authoring and deploy cannot disagree.
|
||||
/// </remarks>
|
||||
public static class RawTagRowRules
|
||||
{
|
||||
/// <summary>One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null.</summary>
|
||||
/// <param name="readWrite">Whether the row is ReadWrite.</param>
|
||||
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
|
||||
/// <param name="tagConfig">The row's TagConfig.</param>
|
||||
public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig);
|
||||
|
||||
/// <summary>One driver's dictated data type for a TagConfig, or null when the operator chooses.</summary>
|
||||
/// <param name="tagConfig">The row's TagConfig.</param>
|
||||
public delegate string? DataTypeRule(string? tagConfig);
|
||||
|
||||
private static readonly Dictionary<string, (RowRule Row, DataTypeRule DataType, bool ReadOnly)> Rules =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true),
|
||||
};
|
||||
|
||||
/// <summary>The driver types that carry row rules (test seam / documentation).</summary>
|
||||
public static IReadOnlyCollection<string> DriverTypesWithRules => Rules.Keys;
|
||||
|
||||
/// <summary>Why a row may not be saved for this driver, or null when it may.</summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="accessLevel">The row's access level.</param>
|
||||
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
|
||||
/// <param name="tagConfig">The row's (normalized) TagConfig.</param>
|
||||
/// <returns>The refusal, or null.</returns>
|
||||
public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) =>
|
||||
driverType is not null && Rules.TryGetValue(driverType, out var r)
|
||||
? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig)
|
||||
: null;
|
||||
|
||||
/// <summary>True for a driver whose tags can never be writable (the access level is fixed at Read).</summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <returns>Whether the access level is fixed.</returns>
|
||||
public static bool IsReadOnlyDriver(string? driverType) =>
|
||||
driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Tag.DataType</c> the driver dictates for this TagConfig (so a form must lock the field to it), or
|
||||
/// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet).
|
||||
/// </summary>
|
||||
/// <param name="driverType">The driver type.</param>
|
||||
/// <param name="tagConfig">The TagConfig.</param>
|
||||
/// <returns>The dictated type name, or null.</returns>
|
||||
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
|
||||
driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null;
|
||||
}
|
||||
@@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
|
||||
/// and carry the data type the driver publishes — the same rule the deploy gate applies.
|
||||
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — <see cref="RawTagRowRules"/>, the
|
||||
/// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies.
|
||||
/// </summary>
|
||||
private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
|
||||
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
|
||||
? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
|
||||
input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
|
||||
: null;
|
||||
RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig);
|
||||
|
||||
/// <summary>
|
||||
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
|
||||
|
||||
Reference in New Issue
Block a user