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("·", "·"));
+ }
}