docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)

Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:25 -04:00
parent 3a217b9484
commit 0ffd4bdbea
217 changed files with 1224 additions and 695 deletions
@@ -228,6 +228,11 @@ public static class AuthEndpoints
}
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
/// <param name="context">The current HTTP context.</param>
/// <param name="antiforgery">Antiforgery service used to validate the request.</param>
/// <param name="tracker">Tracker that records the session activity.</param>
/// <param name="clock">Time source for the activity timestamp.</param>
/// <returns>An HTTP result indicating whether the activity was recorded.</returns>
public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery,
SessionActivityTracker tracker, TimeProvider clock)
{
@@ -13,11 +13,16 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
public static class ClaimsPrincipalExtensions
{
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <returns>The authoritative authentication state.</returns>
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
=> provider is CookieAuthenticationStateProvider cookie
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
/// <summary>Checks the current grant before invoking a directly audited application service.</summary>
/// <param name="provider">The authentication state provider.</param>
/// <param name="role">The role the current session must hold.</param>
/// <returns>The username of the authorized principal.</returns>
public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role)
{
var principal = (await provider.GetAuthoritativeStateAsync()).User;
@@ -23,6 +23,9 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
{
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
@@ -36,6 +39,7 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
/// <returns>The authoritative authentication state.</returns>
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
private async Task<AuthenticationState> RefreshAsync(bool failClosed)
@@ -23,7 +23,7 @@ public static class BrowserTime
/// <param name="browserUtcOffsetMinutes">
/// The value of JavaScript <c>new Date().getTimezoneOffset()</c>: the number
/// of minutes that, <b>added</b> to local time, yields UTC. It is positive
/// for time zones behind UTC (e.g. +300 for UTC-5) and negative for zones
/// for time zones behind UTC (e.g. +300 for five hours behind UTC) and negative for zones
/// ahead (e.g. -120 for UTC+2).
/// </param>
/// <returns>The equivalent instant in UTC, or <c>null</c> when the input is null.</returns>
@@ -79,7 +79,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
/// <summary>
/// Resolves <c>{"$ref":"lib:Name"}</c> nodes in a method's parameter schema so the raw-JSON
/// escape hatch validates against the same resolved shape the form renders (#37).
/// escape hatch validates against the same resolved shape the form renders.
/// </summary>
[Inject] private ISchemaLibraryQueryService SchemaLibrary { get; set; } = default!;
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
@@ -154,7 +154,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _scriptParamsValid;
/// <summary>
/// Method entry (#37): the chosen method's resolved root schema (null when it declares no
/// Method entry: the chosen method's resolved root schema (null when it declares no
/// parameters or the definitions do not parse) and its top-level fields — the card renders
/// only when there is at least one. The values live in <see cref="_methodParams"/>, the same
/// coerced value tree <c>ParameterValueForm</c> emits for the script pane, so the request
@@ -166,7 +166,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private bool _methodParamsValid;
/// <summary>
/// Raw-JSON escape hatch for the whole method payload (#37): paste the exact body an
/// Raw-JSON escape hatch for the whole method payload: paste the exact body an
/// external caller would send. The text is parsed and schema-validated on every edit;
/// only a valid document reaches <see cref="_methodParams"/>, which stays the single source
/// of truth in both modes.
@@ -214,6 +214,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// on a step are NOT here — they are reported separately as
/// <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary>
/// <param name="graph">The flow graph to count log lines in, or null.</param>
/// <returns>The error, warning, info and total log-line counts.</returns>
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
{
if (graph is null)
@@ -706,7 +708,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing.
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
// Read the OUTCOME's own flag — FlowDebugService copies the raw result's
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
// member with no consumer.
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
@@ -721,6 +723,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
/// <param name="known">Events already held by the page, or null.</param>
/// <param name="fresh">Newly read Replay events, or null.</param>
/// <returns>The merged event list.</returns>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
var byId = new Dictionary<Guid, AuditEvent>();
@@ -852,7 +857,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
}
/// <inheritdoc/>
/// <summary>Cancels any pending outcome refresh when the component is disposed.</summary>
public void Dispose() => CancelPendingRefresh();
private async Task LoadReplayAsync()
@@ -972,7 +977,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
/// <summary>
/// Form ⇄ Raw JSON (#37). Entering raw mode seeds the editor from the current value tree so
/// Form ⇄ Raw JSON. Entering raw mode seeds the editor from the current value tree so
/// the operator edits what the form would have sent; leaving it re-mounts the form over the
/// last VALID tree (the form is keyed on the method, and it is not rendered while raw mode is
/// on, so it mounts fresh and re-reports validity from the tree).
@@ -1054,6 +1059,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <c>ParameterValueForm</c> emits and what the raw editor parsed), or null when the method
/// declares no parameters. Run is gated on validity, so nothing here re-checks the tree.
/// </summary>
/// <returns>The serialized parameters, or null when the method declares none.</returns>
internal JsonElement? BuildParameters()
=> _inputFields.Count == 0 ? null : JsonSerializer.SerializeToElement(_methodParams);
@@ -1062,6 +1068,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// nothing. <c>ParameterValueForm</c> already emits the coerced value tree (scalars as
/// bool/long/double/string, objects as dictionaries, lists as lists), so it serializes as-is.
/// </summary>
/// <returns>The serialized parameters, or null when the form produced nothing.</returns>
internal JsonElement? BuildScriptParameters()
=> _scriptParams.Count == 0 ? null : JsonSerializer.SerializeToElement(_scriptParams);
@@ -1092,7 +1099,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// <summary>
/// Restores a persisted value tree only when it still validates against the method's current
/// schema — a stale tree (the schema changed, or the pre-#37 flat string-per-field entry) is
/// schema — a stale tree (the schema changed, or the older flat string-per-field entry) is
/// dropped rather than painted into the form as errors.
/// </summary>
private async Task RestoreInputsAsync(int methodId)
@@ -123,6 +123,10 @@ public partial class InstanceConfigure
/// counts: it pins a reportable template alarm off for this instance, which is a
/// real override and not the same as inheriting.
/// </summary>
/// <param name="overrideJson">The override JSON entered in the modal, if any.</param>
/// <param name="priority">The priority override, if one was chosen.</param>
/// <param name="mesReportable">The MES-reportable override, if one was chosen.</param>
/// <returns><c>true</c> when the modal carries any override worth persisting; otherwise <c>false</c>.</returns>
internal static bool HasOverrideDelta(string? overrideJson, int? priority, bool? mesReportable)
=> !string.IsNullOrWhiteSpace(overrideJson) || priority.HasValue || mesReportable.HasValue;
@@ -112,7 +112,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// <summary>
/// Guards <see cref="_session"/> together with <see cref="_disposed"/> (register row
/// 103, the P6-6 lesser instance — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// 103 — mirrors <c>DebugView</c>'s <c>_sessionLock</c>).
/// <see cref="TryLoadAsync"/> awaits <see cref="IBundleImporter.LoadAsync"/>, and the
/// circuit can be torn down inside that window; publishing the freshly-opened session
/// and observing disposal must be one atomic step so exactly one side (this method or
@@ -258,7 +258,7 @@ public partial class TransportImport : ComponentBase, IDisposable
var loaded = await BundleImporter.LoadAsync(
stream, passphrase, CancellationToken.None, ClientAddress.ClientAddress);
// Register row 103 (P6-6 lesser instance): the component may have been
// Register row 103: the component may have been
// disposed while LoadAsync was in flight (navigation away mid-upload).
// Publish and re-check disposal atomically under _sessionLock so exactly
// one side tears the freshly-opened session down.
@@ -278,9 +278,9 @@ public partial class TransportImport : ComponentBase, IDisposable
}
catch (ArgumentException)
{
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
// Encrypted bundle, no passphrase supplied — caller advances to the passphrase step.
// We deliberately do NOT set _errorMessage here; the page surfaces
// an empty Step-2 prompt instead.
// an empty passphrase prompt instead.
_session = null;
throw;
}
@@ -977,7 +977,7 @@ public partial class TransportImport : ComponentBase, IDisposable
/// Register row 103: the flag is set and the session taken under the same lock
/// <see cref="TryLoadAsync"/> publishes through, so a <see cref="TryLoadAsync"/> still
/// awaiting <see cref="IBundleImporter.LoadAsync"/> either hands the session over here
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s P6-6 fix).
/// or abandons it itself — never neither (mirrors <c>DebugView</c>'s fix).
/// </para>
/// </summary>
public void Dispose()
@@ -138,6 +138,8 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Formats a coordinate culture-invariantly.</summary>
/// <param name="v">The value to format.</param>
/// <returns>The value formatted with up to two decimals using the invariant culture.</returns>
public static string F(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
private string RenderedWidth => F(_layout.Width * Math.Max(0.1, Zoom));
@@ -199,6 +201,8 @@ public partial class FlowGraphView : ComponentBase
=> n.Step != null && n.Kind is FlowStepKind.ApiCall or FlowStepKind.DbCall or FlowStepKind.Notify;
/// <summary>Pill wording for an outbound step's folded outcome (plan D3: the latest lifecycle row decides).</summary>
/// <param name="outcome">The folded outcome of the outbound step.</param>
/// <returns>The short pill label for the outcome.</returns>
public static string PillText(FlowStepOutcome outcome) => outcome switch
{
FlowStepOutcome.Ok => "Ok",
@@ -224,6 +228,9 @@ public partial class FlowGraphView : ComponentBase
/// sync database call's operation detail (<c>write · 3 rows</c>) kept in front of it. The projector's
/// subtitle already leads with the audit status, which the pill now carries, so that head is dropped.
/// </summary>
/// <param name="kind">The kind of the outbound step.</param>
/// <param name="subtitle">The step's projected subtitle, which may lead with the audit status.</param>
/// <returns>The mode text shown after the status pill.</returns>
public static string OutboundMode(FlowStepKind kind, string? subtitle)
{
if (kind == FlowStepKind.Notify) return "notify";
@@ -243,6 +250,8 @@ public partial class FlowGraphView : ComponentBase
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
/// never silently drops a line.
/// </summary>
/// <param name="s">The step whose log lines are counted; may be null.</param>
/// <returns>The error, warning and info line counts.</returns>
internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)
{
if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0);
@@ -260,6 +269,8 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
/// <param name="counts">The per-severity log line counts.</param>
/// <returns>The hover text listing the non-zero levels.</returns>
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
{
var (errors, warnings, info) = counts;
@@ -273,12 +284,14 @@ public partial class FlowGraphView : ComponentBase
/// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace. Parsing is
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel
/// <see cref="ExceptionHeader.Parse"/> (the same parser the step detail panel
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
/// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
/// Null for every non-failed step.
/// </summary>
/// <param name="s">The step to build the subtitle for.</param>
/// <returns>The exception subtitle, or null for a step that did not fail.</returns>
internal static string? ExceptionSubtitle(FlowStep s)
{
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
@@ -305,6 +318,8 @@ public partial class FlowGraphView : ComponentBase
/// than one row — the submit→terminal wall time with a leading <c>+</c> (<c>+4.1 s</c>), since that
/// span is elapsed time downstream of the script, not time the script spent (node anatomy).
/// </summary>
/// <param name="n">The layout node to describe.</param>
/// <returns>The formatted duration text for the node's head row.</returns>
public static string Duration(FlowLayoutNode n)
{
if (n.Step?.DurationMs is not int ms)
@@ -317,6 +332,9 @@ public partial class FlowGraphView : ComponentBase
}
/// <summary>Clips a gutter label to what fits, keeping the full text in a <c>title</c>.</summary>
/// <param name="text">The label text; may be null.</param>
/// <param name="max">The maximum number of characters to keep.</param>
/// <returns>The text, clipped to fit when it is longer than the maximum.</returns>
public static string Clip(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -329,6 +347,8 @@ public partial class FlowGraphView : ComponentBase
/// 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.
/// </summary>
/// <param name="titles">The node titles on the lane.</param>
/// <returns>The shared dotted prefix, or null when there is none worth showing.</returns>
public static string? CommonDottedPrefix(IReadOnlyList<string> titles)
{
if (titles.Count < 2) return null;
@@ -352,6 +372,8 @@ public partial class FlowGraphView : ComponentBase
/// camel-case boundaries so a wrapped title breaks at <c>DelmiaRecipe|Download</c> rather than
/// mid-word; the browser still falls back to breaking anywhere for a single overlong segment.
/// </summary>
/// <param name="text">The title text; may be null.</param>
/// <returns>The text with zero-width spaces inserted at preferred break points.</returns>
public static string WrapHints(string? text)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
@@ -373,6 +395,9 @@ public partial class FlowGraphView : ComponentBase
/// Removes <paramref name="prefix"/> where it starts <paramref name="text"/> or a comma-separated
/// item within it (the write subtitle is <c>a.X = 1, a.Y = 2</c>); null/empty text passes through.
/// </summary>
/// <param name="text">The text to strip; may be null.</param>
/// <param name="prefix">The prefix to remove; may be null.</param>
/// <returns>The text with the prefix removed.</returns>
public static string? StripPrefix(string? text, string? prefix)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(prefix)) return text;
@@ -385,6 +410,14 @@ public partial class FlowGraphView : ComponentBase
/// <c>&lt;text&gt;</c> tag for its own markup-mode switch and refuses attributes on it,
/// so the SVG element of the same name cannot be written literally inside a code block.
/// </summary>
/// <param name="x">The x coordinate.</param>
/// <param name="y">The y coordinate.</param>
/// <param name="cssClass">The CSS class for the element.</param>
/// <param name="content">The text content, encoded by this method.</param>
/// <param name="anchor">The optional <c>text-anchor</c> value.</param>
/// <param name="title">The optional tooltip title.</param>
/// <param name="dataTest">The optional <c>data-test</c> attribute value.</param>
/// <returns>The encoded SVG text element markup.</returns>
public static MarkupString SvgText(string x, string y, string cssClass, string content, string? anchor = null, string? title = null, string? dataTest = null)
{
var sb = new StringBuilder("<text x=\"").Append(x).Append("\" y=\"").Append(y).Append("\" class=\"").Append(cssClass).Append('"');
@@ -427,6 +460,10 @@ public partial class FlowGraphView : ComponentBase
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <param name="isAsync">True when the connector is an async branch.</param>
/// <returns>The midpoint of the connector's vertical run.</returns>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{
var x1 = a.X + NodeWidth;
@@ -460,6 +497,7 @@ public partial class FlowGraphView : ComponentBase
/// </summary>
/// <param name="graph">The real graph, or null.</param>
/// <param name="predicted">The prediction, or null.</param>
/// <returns>The computed layout.</returns>
public static FlowLayout ComputeLayout(FlowGraph? graph, PredictedFlow? predicted)
{
var hasReal = graph is { Steps.Count: > 0 };
@@ -652,6 +690,8 @@ public partial class FlowGraphView : ComponentBase
/// up to <see cref="ExternalSublabelTargets"/> named, the rest folded into <c>+n</c>
/// (<c>Delmia.AckRecipe · MesDb · Planners +1</c>). Empty when the lane is unused.
/// </summary>
/// <param name="distinctTargets">The distinct external targets in first-seen order.</param>
/// <returns>The gutter sublabel text.</returns>
public static string ExternalSublabel(IReadOnlyList<string> distinctTargets)
{
if (distinctTargets.Count == 0) return string.Empty;
@@ -666,6 +706,9 @@ public partial class FlowGraphView : ComponentBase
/// crosses a node sitting between the two lanes. Falls back to <see cref="OrthogonalPath"/> when
/// the target is not below the source.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string AsyncPath(FlowLayoutNode a, FlowLayoutNode b)
{
if (b.Y <= a.Y)
@@ -682,6 +725,9 @@ public partial class FlowGraphView : ComponentBase
/// of <paramref name="b"/>: straight when in the same lane, otherwise right-down/up-right
/// through the inter-column gap. A backwards edge (target left of source) loops under.
/// </summary>
/// <param name="a">The source node.</param>
/// <param name="b">The target node.</param>
/// <returns>The SVG path data for the connector.</returns>
public static string OrthogonalPath(FlowLayoutNode a, FlowLayoutNode b)
{
var x1 = a.X + NodeWidth;
@@ -58,8 +58,8 @@ public interface IDialogService
/// button's styling.</param>
/// <param name="confirmLabel">Verb rendered on the confirm button. Only
/// <see cref="DialogSeverity.Destructive"/> falls back to "Delete" when this is
/// blank; the other severities fall back to "Confirm". The row-28 rule still
/// applies — name the action.</param>
/// blank; the other severities fall back to "Confirm". The rule to name the
/// action still applies.</param>
/// <returns>A task that resolves to <c>true</c> when the user confirms, or <c>false</c> when cancelled.</returns>
Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null);
@@ -8,11 +8,17 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
private readonly StringBuilder _retained = new(Math.Max(0, capacity));
private readonly object _gate = new();
private long _dropped;
/// <summary>Number of characters currently retained.</summary>
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
/// <summary>Number of characters discarded because the budget was reached.</summary>
public long DroppedCharacters { get { lock (_gate) return _dropped; } }
/// <inheritdoc />
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<char> buffer)
{
lock (_gate)
@@ -22,24 +28,32 @@ public sealed class BoundedSandboxWriter(int capacity) : StringWriter
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
}
}
/// <inheritdoc />
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
/// <inheritdoc />
public override void WriteLine() => Write(NewLine);
/// <inheritdoc />
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
}
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
public sealed class BoundedSandboxStream(int capacity) : MemoryStream
{
/// <inheritdoc />
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
/// <inheritdoc />
public override void Write(ReadOnlySpan<byte> buffer)
{
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
var bounded = buffer.ToArray();
base.Write(bounded, 0, bounded.Length);
}
/// <inheritdoc />
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
/// <inheritdoc />
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
/// <inheritdoc />
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
}
@@ -521,7 +521,7 @@ public class SandboxAttributeAccessor
/// <summary>
/// Sandbox stand-in for the reset-aware <c>AttributeAccessor.WriteBatchAndWaitAsync</c>
/// overload (Gitea #36): present for editor/compile parity, throws
/// overload: present for editor/compile parity, throws
/// <see cref="ScriptSandboxException"/> when run in Test Run (no device batch-write transport).
/// </summary>
/// <param name="values">The attribute values to write as a batch.</param>
@@ -21,10 +21,22 @@ public static class SandboxWorkerProtocol
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <returns>The sandbox run result.</returns>
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller)
=> await RunCoreAsync(request, services, catalog, caller);
/// <summary>Core run implementation; exposes a callback that reports the child process id once started.</summary>
/// <param name="request">The sandbox run request to execute in the child process.</param>
/// <param name="services">The service provider supplying host services for the run.</param>
/// <param name="catalog">The shared-script catalog the child resolves shared scripts through.</param>
/// <param name="caller">Cancellation token from the caller.</param>
/// <param name="started">Optional callback invoked with the child process id after launch.</param>
/// <returns>The sandbox run result.</returns>
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
{
@@ -163,6 +175,7 @@ public static class SandboxWorkerProtocol
}
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task RunWorkerAsync()
{
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
@@ -195,15 +208,21 @@ public static class SandboxWorkerProtocol
}
finally { _gate.Release(); }
}
/// <inheritdoc />
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
/// <inheritdoc />
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
/// <inheritdoc />
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
{
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
@@ -213,11 +232,15 @@ public static class SandboxWorkerProtocol
if (info.Open) await connection.OpenAsync(cancellationToken);
return connection;
}
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
/// <inheritdoc />
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
/// <inheritdoc />
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
}
@@ -181,7 +181,7 @@ public class ScriptAnalysisService
/// <summary>
/// Shared options for the per-Test-Run return-value JSON serialize in
/// <see cref="SerializeReturn"/> (perf remediation, arch-review WP1.5) — avoids
/// <see cref="SerializeReturn"/> — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved
/// exactly.
/// </summary>
@@ -237,6 +237,9 @@ public class ScriptAnalysisService
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
/// <summary>Execution core invoked only by the disposable worker executable.</summary>
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the run.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
@@ -537,6 +540,9 @@ public class ScriptAnalysisService
return result;
}
/// <summary>Converts a <see cref="JsonElement"/> into the equivalent CLR object (string, number, bool, list, dictionary or null).</summary>
/// <param name="element">The JSON element to convert.</param>
/// <returns>The converted CLR value.</returns>
internal static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
@@ -111,12 +111,11 @@ public static class ServiceCollectionExtensions
// The fan-out itself stays scoped (it holds scoped repositories + the snapshot
// client), but the page resolves it through SharedAlarmSummaryService — a process
// singleton that memoizes the fan-out per site with single-flight, so N circuits
// watching one site cost ONE fan-out per window instead of N (arch-review WP2.4).
// watching one site cost ONE fan-out per window instead of N.
services.AddScoped<AlarmSummaryService>();
services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>();
// Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding
// #8's per-circuit half). Singleton on purpose: the memo has to outlive any one
// Process-level memoized point-in-time KPI snapshots. Singleton on purpose: the memo has to outlive any one
// circuit for N dashboards to share a single SQL round per refresh window.
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// is hit the service appends a <c># Capped at … rows. Use the CLI for larger
/// exports.</c> footer line so an operator can tell a truncated download from
/// a complete one. The header row contains the same 24 columns, in the same
/// order, as the ManagementService CLI's CSV export (arch-review 2026-08-19
/// P6-7 — the two CSV writers were found to have drifted and are kept aligned).
/// order, as the ManagementService CLI's CSV export (the two CSV writers
/// had drifted and are kept aligned).
/// </para>
/// </summary>
public interface IAuditLogExportService
@@ -162,7 +162,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
// ─────────────────────────────────────────────────────────────────────
// CSV helpers — 24 columns, kept aligned with the ManagementService CLI's
// CSV export (arch-review 2026-08-19 P6-7)
// CSV export
// ─────────────────────────────────────────────────────────────────────
/// <summary>The 24 column names, in <see cref="AuditEventView"/> declaration order.</summary>
@@ -179,8 +179,7 @@ public sealed class AuditLogExportService : IAuditLogExportService
/// cleanly on another. Free-text columns are run through
/// <see cref="NeedsFormulaNeutralization"/> before RFC 4180 quoting so a
/// leading <c>=</c>/<c>+</c>/<c>-</c>/<c>@</c>/TAB/CR can't be interpreted as
/// a formula or DDE trigger by a spreadsheet application (CWE-1236;
/// arch-review 2026-08-19 P6-7). GUID/enum/numeric/date/bool columns this
/// a formula or DDE trigger by a spreadsheet application (CWE-1236). GUID/enum/numeric/date/bool columns this
/// formatter generates itself are never neutralized.
/// </summary>
/// <param name="evt">The audit event view to format as a CSV row.</param>
@@ -70,6 +70,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// </summary>
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
/// <param name="auth">Optional authentication state provider used to resolve the current user.</param>
public AuditLogQueryService(
IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend)
{
/// <summary>Fails over central only for a currently authorized administrator.</summary>
/// <returns>The failover outcome message, or <c>null</c> when none was reported.</returns>
public async Task<string?> FailOverCentralAsync()
=> await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator));
/// <summary>Fails over a site only for a currently authorized administrator.</summary>
/// <param name="siteId">The site identifier to fail over.</param>
/// <returns>The outcome of the site failover.</returns>
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId)
=> await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator));
}
@@ -12,6 +12,7 @@ public enum BindingConnectionState { None, Single, Mixed }
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{
/// <summary>True when the row has no bound (or a non-positive) connection id.</summary>
public bool IsUnbound => Row.ConnectionId is null or <= 0;
/// <summary>Effective reference: override if set, else the default.</summary>
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
@@ -32,6 +33,7 @@ public sealed record BindingGroup(
int? CommonConnectionId,
int UnboundCount)
{
/// <summary>True when this group holds the instance's direct (non-module) attributes.</summary>
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
}
@@ -44,12 +46,14 @@ public static class BindingGroupBuilder
{
public const string InstanceGroupKey = "(instance)";
/// <summary>Groups the data-sourced rows by module and infers each group's shared connection and object prefix.</summary>
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
/// <param name="knownConnectionIds">
/// The site's data connection ids. When supplied, a row whose connection id is not
/// in the set is treated as UNBOUND — a binding to a deleted connection renders as
/// "— none —" and the deploy gate rejects it, so the pills must agree.
/// </param>
/// <returns>One <see cref="BindingGroup"/> per module (or the instance group).</returns>
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
{
if (knownConnectionIds is not null)
@@ -153,6 +157,10 @@ public static class BindingGroupBuilder
/// Save path is an upsert and a removal could not persist anyway. Surrounding
/// whitespace and a trailing '.' are tolerated.
/// </summary>
/// <param name="group">The group whose members are rewritten.</param>
/// <param name="connectionId">The connection to bind, or null to keep each member's current connection.</param>
/// <param name="objectPath">The object path prefix, or blank to clear the overrides.</param>
/// <returns>One change per group member.</returns>
public static IReadOnlyList<BindingChange> ApplyObject(BindingGroup group, int? connectionId, string? objectPath)
{
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
{
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="instanceId">The existing instance id, or null when creating.</param>
/// <param name="createSiteId">The target site id when creating an instance.</param>
/// <param name="operation">The operation to run, given the instance service and the acting username.</param>
/// <returns>The operation's result.</returns>
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
{
@@ -36,6 +41,8 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
}
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public async Task<object?> ExecuteAsync(object command)
{
var principal = (await auth.GetAuthoritativeStateAsync()).User;
@@ -52,6 +59,9 @@ public sealed class ConfigurationCommandService(IServiceProvider services, Authe
public static class ConfigurationCommandExtensions
{
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
/// <param name="services">The service provider used to resolve the command service.</param>
/// <param name="command">The configuration command to execute.</param>
/// <returns>The command's result, if any.</returns>
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
}
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// NAME keys back to <c>DataConnection.Id</c> using the same site repository the
/// design page loads from. Health reports key connections by <c>Name</c>, the
/// page renders nodes by id, so the join is per-site Name → Id; doing it per-site
/// avoids cross-site name collisions (two sites can each own a "PLC-1").
/// avoids cross-site name collisions (two sites can each own a "MainPlc").
/// </remarks>
public sealed class ConnectionHealthQueryService : IConnectionHealthQueryService
{
@@ -59,6 +59,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// the run itself at <c>timeoutSeconds + RunGrace</c> (see <c>FlowDebugRunner.RunMethodAsync</c>),
/// so the client must wait at least that long or it cuts the run off before the server does.
/// </summary>
/// <param name="timeoutSeconds">The method's own timeout in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan RunAskTimeoutFor(int timeoutSeconds)
{
var t = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds)) + FlowDebugRunner.RunGrace + ClientSlack;
@@ -69,6 +71,8 @@ public sealed class FlowDebugService : IFlowDebugService
/// Client-side Ask wait for a WRITE-kind script run (<see cref="RunScriptAsync"/>): the
/// trigger-wait window plus <see cref="ClientSlack"/>.
/// </summary>
/// <param name="waitSeconds">The trigger-wait window in seconds.</param>
/// <returns>The Ask timeout to use for the run.</returns>
public static TimeSpan ValueChangeAskTimeoutFor(int waitSeconds)
=> TimeSpan.FromSeconds(Math.Max(1, waitSeconds)) + ClientSlack;
@@ -110,6 +114,14 @@ public sealed class FlowDebugService : IFlowDebugService
IReadOnlyList<FlowScriptOption> Options);
/// <summary>Initializes the service.</summary>
/// <param name="holder">Holder for the management actor reference.</param>
/// <param name="auth">Provides the caller's authentication state.</param>
/// <param name="inboundApi">Repository for inbound API methods.</param>
/// <param name="templates">Repository for templates and instances.</param>
/// <param name="pipeline">Flattening pipeline used to resolve instance scripts.</param>
/// <param name="comms">Communication service for central-to-site requests.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="sites">Repository for sites.</param>
public FlowDebugService(
ManagementActorHolder holder,
AuthenticationStateProvider auth,
@@ -44,7 +44,7 @@ public interface IAlarmSummaryService
/// <summary>
/// Builds an <see cref="AlarmSummaryResult"/> directly from an in-memory live
/// snapshot (plan #10, Task 5) — the current alarms served by
/// snapshot — the current alarms served by
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.ISiteAlarmLiveCache.GetCurrentAlarms"/>
/// — instead of fanning out per-instance snapshot Asks. Each
/// <see cref="AlarmStateChanged"/> is flattened to one
@@ -28,6 +28,9 @@ public sealed record FlowDebugOutcome(
public bool Success => Graph != null && Error == null;
/// <summary>Creates a failed outcome.</summary>
/// <param name="error">The error message.</param>
/// <param name="errorCode">The machine-readable error code.</param>
/// <returns>A failed outcome carrying the error.</returns>
public static FlowDebugOutcome Fail(string error, string errorCode) => new(null, null, error, errorCode);
}
@@ -83,18 +86,21 @@ public interface IFlowDebugService
/// <summary>The instance's scripts from its flattened configuration, ordered by canonical name; empty when the instance does not exist.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The instance's scripts ordered by canonical name.</returns>
Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>Predicts the flow of running <paramref name="scriptName"/>: the write + spawned script for a write kind, a RouteCall root + the script for a call kind.</summary>
/// <param name="instanceId">The instance id.</param>
/// <param name="scriptName">Canonical script name (case-insensitive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The predicted flow for the script.</returns>
Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default);
/// <summary>Runs an inbound API method with tracing on the active central node and returns the flow.</summary>
/// <param name="apiMethodId">The method id.</param>
/// <param name="parameters">Flat JSON object of parameters, or null.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunMethodAsync(int apiMethodId, JsonElement? parameters, CancellationToken cancellationToken = default);
/// <summary>Runs a script by name (write kinds take <paramref name="value"/>, call kinds <paramref name="parameters"/>) and returns the flow.</summary>
@@ -103,11 +109,13 @@ public interface IFlowDebugService
/// <param name="parameters">Call kinds: flat JSON object of parameters, or null.</param>
/// <param name="value">Write kinds: the new value for the trigger's attribute, in canonical string form.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the run, including the built flow graph.</returns>
Task<FlowDebugOutcome> RunScriptAsync(int instanceId, string scriptName, JsonElement? parameters, string? value, CancellationToken cancellationToken = default);
/// <summary>Rebuilds a past flow from the central audit store.</summary>
/// <param name="executionId">Any execution id in the chain.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The outcome of the replay, including the rebuilt flow graph.</returns>
Task<FlowDebugOutcome> ReplayAsync(Guid executionId, CancellationToken cancellationToken = default);
/// <summary>
@@ -120,6 +128,7 @@ public interface IFlowDebugService
/// </summary>
/// <param name="step">The outbound step to retry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the retry request.</returns>
Task<FlowOutboundActionResult> RetryOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
/// <summary>
@@ -128,5 +137,6 @@ public interface IFlowDebugService
/// </summary>
/// <param name="step">The outbound step to discard.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the discard request.</returns>
Task<FlowOutboundActionResult> DiscardOutboundAsync(FlowStep step, CancellationToken cancellationToken = default);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Process-level memoized point-in-time KPI snapshots shared by every Blazor circuit on
/// this node (arch-review WP2.4, finding #8 — the per-circuit polling half).
/// this node (the per-circuit polling half).
/// <para>
/// Every KPI here is a <em>global, point-in-time aggregate</em>: it does not vary by user,
/// site scope, or role, so N circuits asking for it inside one refresh window are asking
@@ -13,6 +13,9 @@ public sealed class PollGate
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
/// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary>
/// <param name="dispatch">Delegate that posts the refresh.</param>
/// <param name="onError">Callback invoked with any exception thrown by <paramref name="dispatch"/>.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError)
{
if (!TryEnter()) return;
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/> (arch-review WP2.4).
/// Process-level memoizing façade over <see cref="AlarmSummaryService"/>.
/// <para>
/// The Alarm Summary page polls every 15s <em>per circuit</em>, and each poll fans one debug
/// snapshot Ask out to every Enabled instance on the site. Ten operators watching one site
@@ -17,6 +17,9 @@ public static class TemplatePathFormatter
/// Builds the display path for <paramref name="template"/> by walking its
/// folder chain upward through <paramref name="foldersById"/>.
/// </summary>
/// <param name="template">The template whose path is built.</param>
/// <param name="foldersById">Folders keyed by id, used to walk the parent chain.</param>
/// <returns>The folder-qualified display path.</returns>
public static string Format(Template template, IReadOnlyDictionary<int, TemplateFolder> foldersById)
{
var parts = new List<string> { template.Name };