fix(adminui): the cluster-node editor was dead — 500 on load, silent no-op on save

Both defects are PRE-EXISTING (present on master, unrelated to #499) and were found
live-verifying the #499 guard, which lives on this page and was unreachable without
them fixed.

1. **HTTP 500 on load.** `FormModel.ServiceLevelBase` was `byte` to match the
   entity, and `InputNumber<T>`'s static constructor throws outright for
   `System.Byte` — "The type 'System.Byte' is not a supported numeric type". That
   escapes `BuildRenderTree` unhandled, which in Blazor takes the WHOLE page down,
   not one field. `/clusters/{id}/nodes/{nodeId}` and `.../nodes/new` have never
   rendered. Now `int` with the same `[Range(0, 255)]` and a cast on the way to the
   entity, so the stored type is unchanged. (Same failure mode as #504: an
   unhandled throw inside the render tree is a page-kill.)

2. **Save did nothing, silently.** `NodeId` was validated against
   `^[A-Za-z0-9_-]+$`, which forbids the colon — but every NodeId in this system is
   `host:port` (`ClusterRoleInfo` and `ConfigPublishCoordinator` derive it as
   `member.Address.Host:Port`, the seed writes `central-1:4053`, and
   `NodeDeploymentState.NodeId` is FK-bound to it). So every existing node failed
   validation, `OnValidSubmit` never ran, and the button was inert. Pattern now
   allows `:` and `.` (hostnames may be dotted).

3. **Added the missing `<ValidationSummary/>`.** The form had a
   `DataAnnotationsValidator` but nothing rendering its output, so a validation
   failure produced no feedback at all — which is precisely how (2) stayed
   invisible. This is the change that stops the next such defect being silent.

Live-verified on docker-dev (both central nodes rebuilt, since :9200 round-robins
the pair): page 500 → 200 on both routes, and a real edit now persists.
This commit is contained in:
Joseph Doherty
2026-07-26 10:42:04 -04:00
parent 4b9bcddbcc
commit 4cc0215bb4
@@ -36,6 +36,10 @@ else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
<DataAnnotationsValidator />
@* Without this, a validation failure suppresses OnValidSubmit with NO feedback whatsoever —
the Save button simply does nothing and the operator has no way to tell why. That is how the
NodeId-regex defect above stayed invisible. *@
<ValidationSummary class="alert alert-danger py-2 px-3" />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div>
<div style="padding:1rem">
@@ -223,7 +227,7 @@ else
OpcUaPort = _form.OpcUaPort,
DashboardPort = _form.DashboardPort,
ApplicationUri = _form.ApplicationUri,
ServiceLevelBase = _form.ServiceLevelBase,
ServiceLevelBase = (byte)_form.ServiceLevelBase,
Enabled = _form.Enabled,
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
CreatedAt = DateTime.UtcNow,
@@ -239,7 +243,7 @@ else
entity.OpcUaPort = _form.OpcUaPort;
entity.DashboardPort = _form.DashboardPort;
entity.ApplicationUri = _form.ApplicationUri;
entity.ServiceLevelBase = _form.ServiceLevelBase;
entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
entity.Enabled = _form.Enabled;
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
}
@@ -283,14 +287,26 @@ else
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")]
// The ':' and '.' are REQUIRED, not permissive. Every NodeId in this system is "host:port" —
// ClusterRoleInfo and ConfigPublishCoordinator derive it as member.Address.Host:Port, the seed
// writes "central-1:4053", and NodeDeploymentState.NodeId is FK-bound to it. The previous
// pattern forbade the colon, so EVERY existing node failed validation and OnValidSubmit never
// ran: the Save button did nothing at all, silently. Hostnames may also be dotted
// (line3-opc-a.plant.local:4053).
[Required, RegularExpression("^[A-Za-z0-9_.:-]+$")]
public string NodeId { get; set; } = "";
[Required] public string Host { get; set; } = "";
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
public string ApplicationUri { get; set; } = "";
[Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200;
// int, NOT byte. Blazor's InputNumber<T> rejects System.Byte outright — its static
// constructor throws "The type 'System.Byte' is not a supported numeric type", which escapes
// BuildRenderTree unhandled and takes the WHOLE page to an HTTP 500. Binding this field to
// the entity's own byte type meant /clusters/{id}/nodes/{nodeId} and .../nodes/new never
// rendered at all. The Range attribute still holds it to a byte's domain, and SubmitAsync
// casts on the way to the entity, so the stored type is unchanged.
[Range(0, 255)] public int ServiceLevelBase { get; set; } = 200;
public bool Enabled { get; set; } = true;
public string? DriverConfigOverridesJson { get; set; }
}