diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor index 887bf051..83f9d91b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor @@ -81,7 +81,7 @@ else @c.Subject @c.Issuer - @c.Thumbprint[..16]… + @DisplayText.Abbreviate(c.Thumbprint, 16) @c.NotBefore.ToString("u") @c.NotAfter.ToString("u") @if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor index d1635e54..0d73b35e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor @@ -58,7 +58,7 @@ else } else { -
Revision@_lastDeployment.RevisionHash[..16]…
+
Revision@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)
Status@_lastDeployment.Status
Created@_lastDeployment.CreatedAtUtc.ToString("u")
@if (_lastDeployment.SealedAtUtc is not null) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor index ec4dffec..628e7c09 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor @@ -55,7 +55,7 @@ { @Short(d.DeploymentId) - @d.RevisionHash[..12]… + @DisplayText.Abbreviate(d.RevisionHash, 12) @d.Status @d.CreatedBy @d.CreatedAtUtc.ToString("u") @@ -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.", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor index cdc19cb6..04ab3d7d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Scripts.razor @@ -38,7 +38,7 @@ else @s.ScriptId · @s.Name · @s.Language - hash=@s.SourceHash[..12]… + hash=@DisplayText.Abbreviate(s.SourceHash, 12)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs new file mode 100644 index 00000000..cff056da --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/DisplayText.cs @@ -0,0 +1,45 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; + +/// +/// Rendering helpers for operator-facing text that comes out of the database. +/// +/// +/// +/// Why this exists (#504). Several pages abbreviated a hash / thumbprint with the range +/// operator — @s.SourceHash[..12]… — which throws +/// the moment the value is shorter than the slice. +/// In Blazor that escapes BuildRenderTree unhandled and takes the whole page 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 SourceHash values (h1, +/// h-abs-hr200) made /scripts a hard 500 on every stock bring-up. +/// +/// +/// Use for every such prefix. It never throws, and it appends the +/// ellipsis only when it actually truncated — a 2-character hash renders as h1, not +/// h1…, so the display never implies more text than exists. +/// +/// +public static class DisplayText +{ + /// Placeholder rendered for a null / blank value. Matches the em-dash convention the + /// Admin UI already uses for "nothing to show" cells. + public const string Empty = "—"; + + /// + /// The first characters of , followed by an + /// ellipsis when (and only when) characters were dropped. + /// + /// The value to abbreviate. May be null, blank, or shorter than + /// — none of which throw. + /// Maximum number of characters to keep. Values below 1 are treated as 1, + /// so a caller cannot turn a display bug into a crash. + /// for a null/blank value; the value verbatim when it already fits; + /// otherwise the truncated prefix plus an ellipsis. + 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), "…"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs new file mode 100644 index 00000000..462507fa --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DisplayTextTests.cs @@ -0,0 +1,95 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// #504 — , the null/short-safe replacement for the +/// @value[..N] range-operator slices the Admin UI used to abbreviate hashes and thumbprints. +/// +/// +/// The bug being pinned: a range operator over an unvalidated DB string throws +/// out of BuildRenderTree, 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. +/// +[Trait("Category", "Unit")] +public sealed class DisplayTextTests +{ + /// A value longer than the budget is truncated and marked with an ellipsis — the normal + /// case for a 64-char SHA-256. + [Fact] + public void A_long_value_is_truncated_and_marked() + { + DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…"); + } + + /// The regression: a value SHORTER than the budget must render, not throw. This is exactly + /// what a hand-inserted / API-authored SourceHash like h1 hits. + [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); + } + + /// No ellipsis when nothing was dropped — the display must never imply text that is not + /// there. (The old markup appended "…" unconditionally, outside the slice.) + [Fact] + public void A_short_value_gets_no_ellipsis() + { + DisplayText.Abbreviate("h1", 12).ShouldNotContain("…"); + } + + /// A value exactly at the budget is the boundary — it fits, so it is neither truncated nor + /// marked. + [Fact] + public void A_value_exactly_at_the_budget_is_untouched() + { + DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012"); + } + + /// Null / blank render as the Admin UI's em-dash placeholder rather than throwing or + /// producing a stray ellipsis. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void A_null_or_blank_value_renders_the_placeholder(string? value) + { + DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty); + } + + /// 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. + [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…"); + } + + /// 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. + [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)); + } + } + } +}