diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index f3613457..f20f3b1a 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -222,7 +222,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms - **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed. - **Execution path.** `IFlowDebugService` dispatches `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` / `FlowDebugReplayCommand` to the **per-node `ManagementActor`** through `ManagementActorHolder` (the same seam the HTTP `/management` endpoint uses), so the server enforces the Deployer gate and the active-node refusal. A standby node answers the two run commands with `STANDBY_NODE`, which the page renders as an inline alert with the server message verbatim; Replay is served from any node. The same three commands back the CLI's `flow-debug run-method | run-value-change | replay`. - State machine `Idle → Predicted → Running → Completed | Failed | TimedOut`; the status strip shows run number, start, total duration, step/boundary/write counts and the root `ExecutionId` (linking to the Execution Chain page). A run whose route call came back with no child execution and no Site-lane steps shows a *site not traced* note (older site build, or an untraced call). Last entry point and per-method inputs persist in `localStorage`. -- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. +- The renderer is the custom-SVG `FlowGraphView` (no third-party charting) — pure, unit-tested layout; culture-invariant coordinates; each node a keyboard-activatable `role="button"`. Node text is HTML inside an SVG `foreignObject` so the browser measures it: the title wraps to two lines and ellipsizes by width (never by character count), the subtitle by width on one line, and the hover tooltip carries the full text. A lane whose real titles share a dotted head (Tags: `DelmiaReceiver.RecipeDownloadFlag`, `DelmiaReceiver.RecipeProcessedFlag`, …) shows that head once in the lane gutter (`DelmiaReceiver.*`) and each node shows only the tail that tells it apart (`FlowGraphView.CommonDottedPrefix`). ### Alarm Summary (Deployment Role) - A dedicated operator **Alarm Summary** page (`/monitoring/alarms`, `RequireDeployment`) gives a **cross-instance, read-only** roll-up of live alarm state at a site — the operator-facing complement to the per-instance Debug View. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor index 6d85e98d..95a8c399 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -28,7 +28,12 @@ @SvgText("12", LaneLabelY(i), "fg-lane-label", LaneName(i)) @if (!string.IsNullOrEmpty(_layout.LaneSublabels[i])) { - @SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 16), title: _layout.LaneSublabels[i]) + @SvgText("12", F(i * LaneHeight + 40), "fg-lane-sublabel", Clip(_layout.LaneSublabels[i], 22), title: _layout.LaneSublabels[i]) + } + @if (!string.IsNullOrEmpty(_layout.LanePrefixes[i])) + { + @* The dotted head every node in this lane shares — shown here once, stripped from the nodes. *@ + @SvgText("12", F(i * LaneHeight + 56), "fg-lane-prefix", Clip(_layout.LanePrefixes[i] + "*", 22), title: _layout.LanePrefixes[i] + "* (prefix shared by every node in this lane)", dataTest: "flow-lane-prefix") } } @@ -53,12 +58,20 @@ aria-label="@(Eyebrow(node) + ": " + node.Title)" data-test="flow-node" data-key="@node.Key" data-kind="@node.Kind" data-lane="@node.Lane" data-column="@node.Column" @onclick="() => SelectAsync(node)" @onkeydown="e => OnKeyAsync(e, node)"> - @node.Title@(string.IsNullOrEmpty(node.Subtitle) ? "" : " — " + node.Subtitle) - @SvgText("10", "16", "fg-eyebrow", Eyebrow(node)) - @SvgText(F(NodeWidth - 10), "16", "fg-duration", Duration(node), anchor: "end") - @SvgText("10", "40", "fg-title", Clip(node.Title, 20)) - @SvgText("10", "60", "fg-subtitle", Clip(node.Subtitle, 24)) + @* HTML body inside the SVG so the browser measures the text: the title wraps to two + lines and both lines ellipsize by real width, never by a character count, and + nothing can spill past the box. The tooltip carries the full, unstripped text. *@ + +
+
+ @Eyebrow(node) + @Duration(node) +
+
@WrapHints(node.DisplayTitle)
+
@node.DisplaySubtitle
+
+
} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs index fd107bea..fdd5f487 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs @@ -30,6 +30,21 @@ public sealed record FlowLayoutNode( /// Display subtitle (real: the step's; predicted: the note). public string? Subtitle => Step?.Subtitle ?? Predicted?.Note; + + /// + /// The dotted prefix shared by every real title in this node's lane (e.g. DelmiaReceiver.), + /// factored out of the node text and shown once in the lane gutter; null when the lane has none. + /// + public string? LanePrefix { get; init; } + + /// Title with removed — the discriminating tail of a dotted path. + public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title; + + /// Subtitle with removed from each comma-separated item. + public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix); + + /// Full, unclipped text for the hover tooltip. + public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle; } /// One laid-out connector. @@ -47,12 +62,14 @@ public sealed record FlowLayoutEdge(string From, string To, string Path, bool Cr /// Canvas width in SVG units. /// Canvas height in SVG units. /// Per-lane sublabel (index = ), empty when unknown. +/// Per-lane dotted prefix factored out of the node titles (index = ), empty when none. public sealed record FlowLayout( IReadOnlyList Nodes, IReadOnlyList Edges, double Width, double Height, - IReadOnlyList LaneSublabels) + IReadOnlyList LaneSublabels, + IReadOnlyList LanePrefixes) { /// True when there is nothing to draw. public bool IsEmpty => Nodes.Count == 0; @@ -72,10 +89,10 @@ public partial class FlowGraphView : ComponentBase public const double LaneHeight = 110; /// Node width. public const double NodeWidth = 150; - /// Node height. - public const double NodeHeight = 78; - /// Left edge of column 0 (the lane label gutter sits to its left). - public const double ColumnOrigin = 130; + /// Node height (eyebrow row + a two-line title + subtitle inside the lane's 110). + public const double NodeHeight = 84; + /// Left edge of column 0 (the lane label gutter sits to its left; wide enough for a 22-character mono sublabel). + public const double ColumnOrigin = 160; /// Column pitch. public const double ColumnPitch = 170; /// Top padding inside a lane. @@ -175,13 +192,70 @@ public partial class FlowGraphView : ComponentBase private static string Duration(FlowLayoutNode n) => n.Step?.DurationMs is int ms ? ms.ToString(CultureInfo.InvariantCulture) + " ms" : (n.PredictedOnly ? "predicted" : string.Empty); - /// Clips a label to what fits the node width, keeping the full text in a title. + /// Clips a gutter label to what fits, keeping the full text in a title. public static string Clip(string? text, int max) { if (string.IsNullOrEmpty(text)) return string.Empty; return text.Length <= max ? text : text[..(max - 1)] + "…"; } + /// + /// The longest dotted prefix (ending in .) shared by every title, or null when there are + /// fewer than two titles, no common prefix, or stripping it would leave some title empty. Node + /// titles on the Tags lane are attribute paths that mostly differ in their last segment, so the + /// shared head is shown once in the lane gutter and each node shows the tail that tells it apart. + /// + public static string? CommonDottedPrefix(IReadOnlyList titles) + { + if (titles.Count < 2) return null; + var prefix = titles[0]; + foreach (var t in titles.Skip(1)) + { + var n = 0; + while (n < prefix.Length && n < t.Length && prefix[n] == t[n]) n++; + prefix = prefix[..n]; + if (prefix.Length == 0) return null; + } + + var dot = prefix.LastIndexOf('.'); + if (dot < 0) return null; + prefix = prefix[..(dot + 1)]; + return titles.All(t => t.Length > prefix.Length) ? prefix : null; + } + + /// + /// Inserts zero-width spaces (U+200B) after ., /, _ and at lower→upper + /// camel-case boundaries so a wrapped title breaks at DelmiaRecipe|Download rather than + /// mid-word; the browser still falls back to breaking anywhere for a single overlong segment. + /// + public static string WrapHints(string? text) + { + if (string.IsNullOrEmpty(text)) return string.Empty; + var sb = new StringBuilder(text.Length + 8); + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (i > 0 && char.IsUpper(c) && char.IsLower(text[i - 1])) + sb.Append('\u200B'); + sb.Append(c); + if (c is '.' or '/' or '_' && i < text.Length - 1) + sb.Append('\u200B'); + } + + return sb.ToString(); + } + + /// + /// Removes where it starts or a comma-separated + /// item within it (the write subtitle is a.X = 1, a.Y = 2); null/empty text passes through. + /// + public static string? StripPrefix(string? text, string? prefix) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text; + var s = text.StartsWith(prefix, StringComparison.Ordinal) ? text[prefix.Length..] : text; + return s.Replace(", " + prefix, ", ", StringComparison.Ordinal); + } + /// /// Emits an SVG <text> element as pre-encoded markup. Razor reserves the /// <text> tag for its own markup-mode switch and refuses attributes on it, @@ -336,6 +410,16 @@ public partial class FlowGraphView : ComponentBase maxCol = Math.Max(maxCol, col); } + // A lane whose real titles share a dotted head (Tags: "DelmiaReceiver.X", "DelmiaReceiver.Y") + // shows that head once in the gutter and only the tail on each node. + var prefixes = new string?[LaneCount]; + for (var l = 0; l < LaneCount; l++) + { + var titles = keys.Where(k => lane[k] == (FlowLane)l && stepByKey.ContainsKey(k)) + .Select(k => stepByKey[k].Title).ToList(); + prefixes[l] = CommonDottedPrefix(titles); + } + foreach (var k in keys) { var col = column[k]; @@ -343,7 +427,10 @@ public partial class FlowGraphView : ComponentBase var y = LanePad + (int)lane[k] * LaneHeight; stepByKey.TryGetValue(k, out var step); predByKey.TryGetValue(k, out var pn); - nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k))); + nodes.Add(new FlowLayoutNode(k, lane[k], col, x, y, step, pn, ghostKeys.Contains(k)) + { + LanePrefix = step is null ? null : prefixes[(int)lane[k]], + }); } var byKey = nodes.ToDictionary(n => n.Key, StringComparer.Ordinal); @@ -358,7 +445,9 @@ public partial class FlowGraphView : ComponentBase if (maxCol < 0) width = ColumnOrigin + ColumnPitch + RightPad; var height = LaneCount * LaneHeight + LanePad; - return new FlowLayout(nodes, edges, width, height, sublabels.Select(s => s ?? string.Empty).ToArray()); + return new FlowLayout(nodes, edges, width, height, + sublabels.Select(s => s ?? string.Empty).ToArray(), + prefixes.Select(p => p ?? string.Empty).ToArray()); } private static string JoinLabel(string? a, string? b) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css index 2c6a5598..f6cf74ff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css @@ -15,13 +15,15 @@ font-family: var(--bs-body-font-family, system-ui, sans-serif); } -/* Lanes */ +/* Lanes. The gutter labels are emitted as raw markup (SvgText — Razor reserves the tag), + so they carry no CSS-isolation scope attribute and must be reached through ::deep. */ .fg-lane-bg { fill: transparent; } .fg-lane-bg.alt { fill: var(--rule); fill-opacity: 0.25; } .fg-lane-rule { stroke: var(--rule); stroke-width: 1; } -.fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } -.fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } -.fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; } +.fg-wrap ::deep .fg-lane-label { fill: var(--ink-soft); font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } +.fg-wrap ::deep .fg-lane-sublabel{ fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } +.fg-wrap ::deep .fg-lane-prefix { fill: var(--ink-faint); font-size: 0.65rem; font-family: var(--mono); } +.fg-wrap ::deep .fg-empty { fill: var(--ink-faint); font-size: 0.85rem; font-style: italic; } /* Nodes */ .fg-node { cursor: pointer; outline: none; } @@ -45,12 +47,35 @@ .fg-node.timed-out .fg-node-box { stroke: var(--bs-danger, #c0392b); } .fg-node.not-traced .fg-node-box { stroke-dasharray: 1 3; } -.fg-eyebrow { fill: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; } -.fg-duration { fill: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); } -.fg-title { fill: var(--ink); font-size: 0.8rem; font-weight: 600; } -.fg-subtitle { fill: var(--ink-soft); font-size: 0.68rem; } +/* Node body: HTML inside a foreignObject so text is measured by the browser. */ +.fg-body { + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 6px 8px 5px; + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + line-height: 1.2; + color: var(--ink); + font-family: var(--bs-body-font-family, system-ui, sans-serif); +} +.fg-head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; } +.fg-eyebrow { color: var(--ink-faint); font-size: 0.6rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fg-duration { color: var(--ink-soft); font-size: 0.6rem; font-family: var(--mono); white-space: nowrap; flex-shrink: 0; } +.fg-title { + font-size: 0.78rem; + font-weight: 600; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .fg-node.failed .fg-eyebrow, -.fg-node.timed-out .fg-eyebrow { fill: var(--bs-danger, #c0392b); } +.fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); } /* Connectors */ .fg-edge { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index 73dc51c3..15fa9b56 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -216,4 +216,89 @@ public class FlowGraphViewTests : BunitContext CultureInfo.CurrentUICulture = previousUi; } } + + // ── Node text readability (2026-09-06): shared-prefix stripping, width-based clipping, tooltips ── + + [Theory] + [InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.RecipeProcessedFlag" }, "DelmiaReceiver.")] + [InlineData(new[] { "A.B.C.X", "A.B.C.Y", "A.B.D.Z" }, "A.B.")] + [InlineData(new[] { "Same.Path", "Same.Path" }, "Same.")] + [InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag" }, null)] + [InlineData(new[] { "ProcessRecipeDownload", "DelmiaReceiverSimulator" }, null)] + [InlineData(new[] { "NoDotsHere", "NoDotsThere" }, null)] + [InlineData(new[] { "A.B", "A.B.C" }, "A.")] + public void CommonDottedPrefix_FactorsOnlyASharedDottedHeadThatLeavesEveryTail(string[] titles, string? expected) + { + Assert.Equal(expected, FlowGraphView.CommonDottedPrefix(titles)); + } + + [Theory] + [InlineData("DelmiaRecipeDownload", "Delmia\u200BRecipe\u200BDownload")] + [InlineData("DelmiaSim01.ProcessRecipeDownload", "Delmia\u200BSim01.\u200BProcess\u200BRecipe\u200BDownload")] + [InlineData("POST /api/X", "POST /\u200Bapi/\u200BX")] + [InlineData("lower", "lower")] + [InlineData("", "")] + [InlineData(null, "")] + public void WrapHints_MarksCamelCaseAndSeparatorBoundaries(string? input, string expected) + { + Assert.Equal(expected, FlowGraphView.WrapHints(input)); + } + + [Fact] + public void StripPrefix_RemovesTheHeadFromTheTitleAndFromEachSubtitleItem() + { + Assert.Equal("RecipeDownloadFlag", FlowGraphView.StripPrefix("DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.")); + Assert.Equal("X = 1, Y = true", FlowGraphView.StripPrefix("DelmiaReceiver.X = 1, DelmiaReceiver.Y = true", "DelmiaReceiver.")); + Assert.Equal("Other.Z", FlowGraphView.StripPrefix("Other.Z", "DelmiaReceiver.")); + Assert.Null(FlowGraphView.StripPrefix(null, "DelmiaReceiver.")); + Assert.Equal("kept", FlowGraphView.StripPrefix("kept", null)); + } + + [Fact] + public void Layout_TagsLane_FactorsTheSharedDottedHeadIntoTheGutter() + { + var layout = FlowGraphView.ComputeLayout(DelmiaGraph(), null); + + Assert.Equal("DelmiaSim01.", layout.LanePrefixes[(int)FlowLane.Tags]); + Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Central]); + Assert.Equal(string.Empty, layout.LanePrefixes[(int)FlowLane.Site]); + var writes = layout.Nodes.Where(n => n.Kind == FlowStepKind.AttributeWrite).ToList(); + Assert.Equal(["RecipeProcessedFlag", "RecipeDownloadFlag"], writes.Select(w => w.DisplayTitle)); + Assert.All(writes, w => Assert.StartsWith("DelmiaSim01.", w.Title)); + // Site-lane titles share no dotted head, so they render unchanged. + var wait = layout.Nodes.Single(n => n.Kind == FlowStepKind.AttributeWait); + Assert.Equal(wait.Title, wait.DisplayTitle); + } + + [Fact] + public void Layout_PredictedOnlyNodes_NeverGetALanePrefix() + { + var predicted = FlowShapePredictor.PredictSiteScript("s", "Attributes.SetAsync(\"Flag\", true);"); + + var layout = FlowGraphView.ComputeLayout(null, predicted); + + Assert.All(layout.Nodes, n => Assert.Null(n.LanePrefix)); + Assert.All(layout.LanePrefixes, p => Assert.Equal(string.Empty, p)); + } + + [Fact] + public void Render_NodeBodyIsHtmlInAForeignObject_WithStrippedTitleAndFullTextTooltip() + { + var cut = Render(p => p.Add(c => c.Graph, DelmiaGraph())); + + var write = cut.FindAll("g[data-test='flow-node'][data-kind='AttributeWrite']")[0]; + var body = write.QuerySelector("foreignObject [data-test='flow-node-body']")!; + var title = write.QuerySelector("[data-test='flow-node-title']")!.TextContent.Trim(); + // Wrap hints (zero-width spaces) sit at the camel-case boundaries and nowhere else. + Assert.Equal("Recipe\u200BProcessed\u200BFlag", title); + Assert.Equal("RecipeProcessedFlag", title.Replace("\u200B", "")); + Assert.StartsWith("DelmiaSim01.RecipeProcessedFlag", body.GetAttribute("title")); + // The gutter shows the factored head once. + var prefix = cut.Find("[data-test='flow-lane-prefix']"); + Assert.Equal("DelmiaSim01.*", prefix.TextContent.Replace(prefix.QuerySelector("title")?.TextContent ?? "", "").Trim()); + // No character-count clipping in any node body: the browser ellipsizes by width instead. + Assert.All(cut.FindAll("[data-test='flow-node-body']"), b => Assert.DoesNotContain("…", b.TextContent)); + // The gutter sublabel fits without clipping at the widened gutter. + Assert.Contains("site-a · DelmiaSim01", cut.Markup.Replace("·", "·")); + } }