Compare commits

..

2 Commits

Author SHA1 Message Date
Joseph Doherty 47d148daf9 fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.

New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
  display never implies text that isn't there (the old markup appended "…"
  unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.

Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:

  Scripts.razor                    SourceHash    (the reported crash)
  Certificates.razor               Thumbprint    (read off the on-disk store)
  Deployments.razor  x2            RevisionHash
  Clusters/ClusterOverview.razor   RevisionHash

The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.

NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.

Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.

Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
2026-07-26 09:43:48 -04:00
Joseph Doherty 755ae1aa3f merge: re-assert non-normal scripted-alarm conditions after a (re)load (#487)
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 3h14m40s
Closes the redeploy-reset gap on Part 9 scripted-alarm condition nodes — the
alarm analogue of the VirtualTag ReassertValue fix.

A full address-space rebuild re-materialises every condition node fresh, while
the engine's reload produces EmissionKind.None for an alarm whose state did not
change — so nothing wrote the node. Live-reproduced on docker-dev: a cleared-
but-still-UNACKNOWLEDGED alarm came back reporting acknowledged, dropping Retain
to false, so it vanished from ConditionRefresh entirely. The operator's
outstanding alarm silently disappeared from the alarm list on deploy.

ScriptedAlarmHostActor.ReassertConditionNodes now writes one AlarmStateUpdate
per alarm not in the Part 9 no-event position, sourced from the new
ScriptedAlarmEngine.GetProjections(). Node-only by construction — the projection
type carries no EmissionKind, so it cannot become an alerts row or a duplicate
historian record.

Live gate PASSED (pre-fix image vs. rebuilt, central-2): condition absent
pre-fix, present + Unacknowledged + Retain=True post-fix, stamped with its own
LastTransitionUtc; /alerts empty across two re-asserts with the panel proven
live by a real ACTIVATED transition.
2026-07-26 09:18:23 -04:00
6 changed files with 145 additions and 5 deletions
@@ -81,7 +81,7 @@ else
<tr>
<td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td>
<td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
<td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
@@ -58,7 +58,7 @@ else
}
else
{
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div>
<div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
@if (_lastDeployment.SealedAtUtc is not null)
@@ -55,7 +55,7 @@
{
<tr>
<td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td>
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td>
<td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td>
@@ -113,7 +113,7 @@
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch
{
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).",
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.",
@@ -38,7 +38,7 @@ else
<span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span>
<span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Rendering helpers for operator-facing text that comes out of the database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
/// </para>
/// <para>
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
/// <c>h1…</c>, so the display never implies more text than exists.
/// </para>
/// </remarks>
public static class DisplayText
{
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
/// Admin UI already uses for "nothing to show" cells.</summary>
public const string Empty = "—";
/// <summary>
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
/// ellipsis when (and only when) characters were dropped.
/// </summary>
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
/// <paramref name="maxLength"/> — none of which throw.</param>
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
/// so a caller cannot turn a display bug into a crash.</param>
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
/// otherwise the truncated prefix plus an ellipsis.</returns>
public static string Abbreviate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return Empty;
if (maxLength < 1) maxLength = 1;
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
}
}
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
/// </summary>
/// <remarks>
/// The bug being pinned: a range operator over an unvalidated DB string throws
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
/// that matter; the happy path is almost incidental.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DisplayTextTests
{
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
/// case for a 64-char SHA-256.</summary>
[Fact]
public void A_long_value_is_truncated_and_marked()
{
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
}
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
[Theory]
[InlineData("h1", 12)]
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
[InlineData("abc", 16)]
[InlineData("x", 64)]
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
{
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
}
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
[Fact]
public void A_short_value_gets_no_ellipsis()
{
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
}
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
/// marked.</summary>
[Fact]
public void A_value_exactly_at_the_budget_is_untouched()
{
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
}
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
/// producing a stray ellipsis.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_null_or_blank_value_renders_the_placeholder(string? value)
{
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
}
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
/// become a page crash, which is the whole point of this helper.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
{
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
}
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
/// page. Includes the shapes that broke the old code.</summary>
[Fact]
public void It_never_throws_for_any_combination()
{
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
foreach (var value in values)
{
foreach (var budget in budgets)
{
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
}
}
}
}