feat(central-ui): Flow Debugger status strip log counts + inline-truncation notes; detail cross-step selection
This commit is contained in:
@@ -903,4 +903,158 @@ public class FlowDebuggerPageTests : BunitContext
|
||||
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
|
||||
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
|
||||
}
|
||||
|
||||
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
|
||||
|
||||
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
|
||||
private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") =>
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
|
||||
occurredAtUtc: DateTime.UtcNow.AddMilliseconds(ms), actor: "Process", target: "Process", sourceNode: "node-a",
|
||||
executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", requestSummary: message,
|
||||
extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = stepEventId }.ToJson());
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="GraphOutcome"/> flow (inbound → route → site run) whose site run carries the asked-for
|
||||
/// log lines, optionally with <paramref name="unattached"/> lines belonging to an execution with no steps
|
||||
/// (the builder drops and counts those) and the inline log-truncation flag set.
|
||||
/// </summary>
|
||||
private static FlowDebugOutcome LoggedOutcome(int errors = 0, int warnings = 0, int infos = 0,
|
||||
bool logsTruncated = false, int unattached = 0)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow;
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
|
||||
requestSummary: "{}", responseSummary: "{}"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
|
||||
};
|
||||
|
||||
var ms = 3;
|
||||
for (var i = 0; i < errors; i++) events.Add(LogLine(child, "error", ms++));
|
||||
for (var i = 0; i < warnings; i++) events.Add(LogLine(child, "warn", ms++));
|
||||
for (var i = 0; i < infos; i++) events.Add(LogLine(child, "info", ms++));
|
||||
for (var i = 0; i < unattached; i++) events.Add(LogLine(Guid.NewGuid(), "info", ms++));
|
||||
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), logsTruncated);
|
||||
return new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, logsTruncated), raw, null, null, logsTruncated);
|
||||
}
|
||||
|
||||
/// <summary>Picks the method, runs it against <paramref name="outcome"/> and waits for the status strip.</summary>
|
||||
private IRenderedComponent<FlowDebuggerPage> RunWith(FlowDebugOutcome outcome)
|
||||
{
|
||||
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
|
||||
var cut = Render<FlowDebuggerPage>();
|
||||
cut.Find("select[data-test='flow-method-select']").Change("4");
|
||||
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
|
||||
cut.Find("button[data-test='flow-run']").Click();
|
||||
cut.WaitForElement("[data-test='flow-step-count']");
|
||||
return cut;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusStrip_ShowsLogCountsByLevel()
|
||||
{
|
||||
RegisterServices();
|
||||
|
||||
var cut = RunWith(LoggedOutcome(errors: 1, warnings: 2, infos: 3));
|
||||
|
||||
Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing()
|
||||
{
|
||||
RegisterServices();
|
||||
|
||||
var cut = RunWith(LoggedOutcome());
|
||||
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-log-counts']"));
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']"));
|
||||
Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusStrip_ShowsTheInlineLogTruncationBadge()
|
||||
{
|
||||
// D7: the inline copy dropped low-priority rows; the audit store — and so Replay — is complete.
|
||||
RegisterServices();
|
||||
|
||||
var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true));
|
||||
|
||||
Assert.Contains("Replay is complete", cut.Find("[data-test='flow-logs-truncated']").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep()
|
||||
{
|
||||
RegisterServices();
|
||||
|
||||
var cut = RunWith(LoggedOutcome(infos: 1, unattached: 2));
|
||||
|
||||
Assert.Contains("2 log line(s) could not be placed", cut.Find("[data-test='flow-unattached-logs']").TextContent);
|
||||
// The dropped lines are not counted on the strip — it counts only what landed on a step.
|
||||
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines()
|
||||
{
|
||||
// A Log.Error line is not a failed step: the run state is decided by step outcomes alone.
|
||||
RegisterServices();
|
||||
|
||||
var cut = RunWith(LoggedOutcome(errors: 2));
|
||||
|
||||
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
|
||||
Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines()
|
||||
{
|
||||
RegisterServices();
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var writeId = Guid.NewGuid();
|
||||
var t0 = DateTime.UtcNow;
|
||||
var events = new List<AuditEvent>
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
|
||||
requestSummary: "{}", responseSummary: "{}"),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
|
||||
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
|
||||
eventId: writeId, occurredAtUtc: t0.AddMilliseconds(3), target: "Sim01.Flag", executionId: child,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 2,
|
||||
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Sim01.Flag", "true", true, null)] }.ToJson()),
|
||||
LogLine(child, "info", 4), // → the ScriptRun anchor
|
||||
LogLine(child, "warn", 5, stepEventId: writeId), // → the write step
|
||||
};
|
||||
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
|
||||
var cut = RunWith(new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null));
|
||||
|
||||
cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']").Click();
|
||||
cut.Find("[data-test='flow-step-tab-log']").Click();
|
||||
var before = cut.Find("[data-test='flow-step-title']").TextContent.Trim();
|
||||
var hint = cut.Find("[data-test='flow-step-log-hint'] button");
|
||||
var otherTitle = hint.TextContent.Trim();
|
||||
|
||||
hint.Click();
|
||||
|
||||
// The page owns the selection: clicking the hint moves the detail panel to the sibling step.
|
||||
Assert.NotEqual(before, otherTitle);
|
||||
Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
Assert.IsType<DiscardSiteCallRequest>(Assert.Single(_siteCallRequests));
|
||||
}
|
||||
|
||||
private static string SuccessPayload(int events, bool completed = true, bool truncated = false)
|
||||
private static string SuccessPayload(int events, bool completed = true, bool truncated = false, bool logsTruncated = false)
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
@@ -273,10 +273,36 @@ public sealed class FlowDebugServiceTests : IDisposable
|
||||
occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root,
|
||||
sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80));
|
||||
|
||||
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1));
|
||||
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1), logsTruncated);
|
||||
return JsonSerializer.Serialize(result, ServerJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph()
|
||||
{
|
||||
// Step logs D7: the run result's inline log-truncation flag is the page's only way to
|
||||
// know the lines it holds are incomplete — carry it onto the outcome AND into the graph.
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3, logsTruncated: true)));
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.True(outcome.LogsTruncated);
|
||||
Assert.True(outcome.Graph!.LogsTruncated);
|
||||
Assert.True(outcome.Raw!.LogsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse()
|
||||
{
|
||||
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3)));
|
||||
|
||||
var outcome = await svc.RunMethodAsync(4, null);
|
||||
|
||||
Assert.False(outcome.LogsTruncated);
|
||||
Assert.False(outcome.Graph!.LogsTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user