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:
@@ -36,6 +36,10 @@ else
|
|||||||
{
|
{
|
||||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
|
||||||
<DataAnnotationsValidator />
|
<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">
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
<div class="panel-head">Identity</div>
|
<div class="panel-head">Identity</div>
|
||||||
<div style="padding:1rem">
|
<div style="padding:1rem">
|
||||||
@@ -223,7 +227,7 @@ else
|
|||||||
OpcUaPort = _form.OpcUaPort,
|
OpcUaPort = _form.OpcUaPort,
|
||||||
DashboardPort = _form.DashboardPort,
|
DashboardPort = _form.DashboardPort,
|
||||||
ApplicationUri = _form.ApplicationUri,
|
ApplicationUri = _form.ApplicationUri,
|
||||||
ServiceLevelBase = _form.ServiceLevelBase,
|
ServiceLevelBase = (byte)_form.ServiceLevelBase,
|
||||||
Enabled = _form.Enabled,
|
Enabled = _form.Enabled,
|
||||||
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -239,7 +243,7 @@ else
|
|||||||
entity.OpcUaPort = _form.OpcUaPort;
|
entity.OpcUaPort = _form.OpcUaPort;
|
||||||
entity.DashboardPort = _form.DashboardPort;
|
entity.DashboardPort = _form.DashboardPort;
|
||||||
entity.ApplicationUri = _form.ApplicationUri;
|
entity.ApplicationUri = _form.ApplicationUri;
|
||||||
entity.ServiceLevelBase = _form.ServiceLevelBase;
|
entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
|
||||||
entity.Enabled = _form.Enabled;
|
entity.Enabled = _form.Enabled;
|
||||||
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
|
||||||
}
|
}
|
||||||
@@ -283,14 +287,26 @@ else
|
|||||||
|
|
||||||
private sealed class FormModel
|
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; } = "";
|
public string NodeId { get; set; } = "";
|
||||||
[Required] public string Host { get; set; } = "";
|
[Required] public string Host { get; set; } = "";
|
||||||
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
|
||||||
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
|
||||||
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
|
||||||
public string ApplicationUri { get; set; } = "";
|
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 bool Enabled { get; set; } = true;
|
||||||
public string? DriverConfigOverridesJson { get; set; }
|
public string? DriverConfigOverridesJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user