feat(central-ui): Flow Debugger page (predict, live run, replay, step detail) + nav entry
/deployment/flow-debugger (RequireDeployment): entry toggle (inbound method | value-change trigger), method picker or Site → Instance → Attribute pickers (site-scope filtered), Live | Replay mode, inputs form generated from the method's ParameterDefinitions via InboundApiSchema (values coerced by declared type into the request JsonElement), status strip (state chip, run #, start, total, steps/boundaries/writes, root ExecutionId link to the Execution Chain page), the FlowGraphView with −/Fit/+ zoom, and FlowStepDetail (tabs Input · Output · Writes · Timing, 'Where this ran' column, Open in Debug View when the instance is known, Audit rows (n) → /audit/log?executionId=). State machine Idle → Predicted → Running → Completed | Failed | TimedOut; STANDBY_NODE renders as an inline warning alert with the server message verbatim; ?methodId= / ?instanceId=&attribute= / ?executionId= drill-ins (the last replays on first render); last entry + inputs persisted per method in localStorage; 'site not traced' label when a run's route call came back with no child execution and no Site-lane steps. NavMenu: Deployment → Flow Debugger. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment
|
||||
@using System.Globalization
|
||||
@using System.Text.Json
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@* Flow Debugger step detail (design 2026-09-05 §4): tabs Input · Output ·
|
||||
Attribute writes · Timing plus a "Where this ran" column with drill-ins. *@
|
||||
|
||||
<div class="card" data-test="flow-step-detail">
|
||||
<div class="card-body">
|
||||
@if (Step is null)
|
||||
{
|
||||
<div class="text-muted small" data-test="flow-step-detail-empty">Select a step in the flow to see its input, output and timing.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2">
|
||||
<div>
|
||||
<div class="fsd-eyebrow">@Step.Kind.ToString().ToUpperInvariant() · @Step.Lane</div>
|
||||
<h6 class="mb-0" data-test="flow-step-title">@Step.Title</h6>
|
||||
@if (!string.IsNullOrEmpty(Step.Subtitle))
|
||||
{
|
||||
<div class="small text-muted">@Step.Subtitle</div>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @OutcomeBadge" data-test="flow-step-outcome">@Step.Outcome</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-8">
|
||||
<ul class="nav nav-tabs nav-tabs-sm" role="tablist">
|
||||
@foreach (var tab in Tabs)
|
||||
{
|
||||
var t = tab;
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link @(_tab == t ? "active" : "")" type="button" role="tab"
|
||||
data-test="@("flow-step-tab-" + t.ToLowerInvariant())"
|
||||
@onclick="() => _tab = t">@t</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="fsd-pane" role="tabpanel">
|
||||
@switch (_tab)
|
||||
{
|
||||
case "Input":
|
||||
<pre class="fsd-json" data-test="flow-step-input">@Pretty(Step.InputJson)</pre>
|
||||
break;
|
||||
case "Output":
|
||||
@if (!string.IsNullOrEmpty(Step.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger small py-1 mb-2" data-test="flow-step-error">@Step.ErrorMessage</div>
|
||||
}
|
||||
<pre class="fsd-json" data-test="flow-step-output">@Pretty(Step.OutputJson)</pre>
|
||||
break;
|
||||
case "Writes":
|
||||
@if (Step.Extra?.Writes is { Count: > 0 } writes)
|
||||
{
|
||||
<table class="table table-sm small mb-0" data-test="flow-step-writes">
|
||||
<thead><tr><th>Attribute</th><th>Written</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var w in writes)
|
||||
{
|
||||
<tr>
|
||||
<td class="font-monospace">@w.Attribute</td>
|
||||
<td class="font-monospace">@(w.Written ?? "—")</td>
|
||||
<td>@(w.Success ? "ok" : (w.Error ?? "failed"))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else if (Step.Kind == FlowStepKind.AttributeWait && Step.Extra is { } wx)
|
||||
{
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-4">Attribute</dt><dd class="col-8 font-monospace">@wx.WaitAttribute</dd>
|
||||
<dt class="col-4">Target</dt><dd class="col-8 font-monospace">@wx.WaitTarget</dd>
|
||||
<dt class="col-4">Timeout</dt><dd class="col-8">@(wx.WaitTimeoutMs is int ms ? ms + " ms" : "—")</dd>
|
||||
<dt class="col-4">Outcome</dt><dd class="col-8">@wx.WaitOutcome</dd>
|
||||
<dt class="col-4">Observed</dt><dd class="col-8 font-monospace">@(wx.ObservedValue ?? "—")</dd>
|
||||
</dl>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small">No attribute writes on this step.</div>
|
||||
}
|
||||
break;
|
||||
case "Timing":
|
||||
<dl class="row small mb-0" data-test="flow-step-timing">
|
||||
<dt class="col-4">Occurred (UTC)</dt><dd class="col-8 font-monospace">@Step.OccurredAtUtc.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)</dd>
|
||||
<dt class="col-4">Duration</dt><dd class="col-8 font-monospace">@(Step.DurationMs is int d ? d.ToString(CultureInfo.InvariantCulture) + " ms" : "—")</dd>
|
||||
<dt class="col-4">Execution</dt><dd class="col-8 font-monospace">@(Step.ExecutionId?.ToString() ?? "—")</dd>
|
||||
<dt class="col-4">Parent</dt><dd class="col-8 font-monospace">@(Step.ParentExecutionId?.ToString() ?? "—")</dd>
|
||||
</dl>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="fsd-eyebrow mb-1">Where this ran</div>
|
||||
<dl class="row small mb-2">
|
||||
<dt class="col-5">Site / node</dt><dd class="col-7 font-monospace">@(Join(Step.SiteId, Step.Node) ?? "central")</dd>
|
||||
<dt class="col-5">Instance</dt><dd class="col-7 font-monospace">@(Step.Instance ?? "—")</dd>
|
||||
<dt class="col-5">Target</dt><dd class="col-7 font-monospace text-break">@(Step.Target ?? "—")</dd>
|
||||
@if (Step.Extra?.TriggerKind is { } tk)
|
||||
{
|
||||
<dt class="col-5">Trigger</dt><dd class="col-7 font-monospace">@tk@(Step.Extra.TriggerAttribute is { } ta ? " · " + ta : "")</dd>
|
||||
}
|
||||
</dl>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@if (SiteDbId is int sid && InstanceDbId is int iid)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm" data-test="open-debug-view"
|
||||
href="@($"/deployment/debug-view?siteId={sid}&instanceId={iid}")">Open in Debug View</a>
|
||||
}
|
||||
@if (Step.ExecutionId is { } exec)
|
||||
{
|
||||
<a class="btn btn-outline-secondary btn-sm" data-test="open-audit-rows"
|
||||
href="@($"/audit/log?executionId={exec}")">Audit rows (@AuditRowCount)</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"];
|
||||
|
||||
/// <summary>The selected step; null renders the placeholder.</summary>
|
||||
[Parameter] public FlowStep? Step { get; set; }
|
||||
|
||||
/// <summary>The whole graph, used to count sibling audit rows of the step's execution.</summary>
|
||||
[Parameter] public FlowGraph? Graph { get; set; }
|
||||
|
||||
/// <summary>Site database id for the Debug View drill-in (known for value-change entries).</summary>
|
||||
[Parameter] public int? SiteDbId { get; set; }
|
||||
|
||||
/// <summary>Instance database id for the Debug View drill-in (known for value-change entries).</summary>
|
||||
[Parameter] public int? InstanceDbId { get; set; }
|
||||
|
||||
private string _tab = "Input";
|
||||
|
||||
private int AuditRowCount
|
||||
=> Step?.ExecutionId is { } exec && Graph != null
|
||||
? Graph.Steps.Count(s => s.ExecutionId == exec && s.Kind != FlowStepKind.Response)
|
||||
: 0;
|
||||
|
||||
private string OutcomeBadge => Step?.Outcome switch
|
||||
{
|
||||
FlowStepOutcome.Ok => "bg-success",
|
||||
FlowStepOutcome.Failed => "bg-danger",
|
||||
FlowStepOutcome.TimedOut => "bg-warning text-dark",
|
||||
FlowStepOutcome.NotTraced => "bg-secondary",
|
||||
_ => "bg-light text-dark",
|
||||
};
|
||||
|
||||
private static string? Join(string? a, string? b)
|
||||
=> string.IsNullOrEmpty(a) ? b : string.IsNullOrEmpty(b) ? a : $"{a} / {b}";
|
||||
|
||||
/// <summary>Pretty-prints JSON when the text is JSON; otherwise returns it as-is (or an em-dash).</summary>
|
||||
internal static string Pretty(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return "—";
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@
|
||||
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
||||
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
||||
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
||||
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
|
||||
</NavRailSection>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
@page "/deployment/flow-debugger"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
|
||||
<PageTitle>Flow Debugger</PageTitle>
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h4 class="mb-3">Flow Debugger</h4>
|
||||
|
||||
@* ── Toolbar ─────────────────────────────────────────────────────────── *@
|
||||
<div class="row g-2 align-items-end mb-2" data-test="flow-toolbar">
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Entry point</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.ValueChange ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-value-change" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.ValueChange)">Value-change trigger</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_mode == FlowMode.Live)
|
||||
{
|
||||
@if (_entry == EntryKind.Method)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm">Method</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-method-select"
|
||||
@bind="_selectedMethodId" @bind:after="OnMethodChangedAsync" disabled="@_running">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var m in _methods)
|
||||
{
|
||||
<option value="@m.Id">@m.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Site</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-site-select"
|
||||
@bind="_selectedSiteId" @bind:after="OnSiteChangedAsync" disabled="@_running">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var s in _sites)
|
||||
{
|
||||
<option value="@s.Id">@s.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Instance</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-instance-select"
|
||||
@bind="_selectedInstanceId" @bind:after="OnInstanceChangedAsync" disabled="@(_running || _selectedSiteId == 0)">
|
||||
<option value="0">— choose —</option>
|
||||
@foreach (var i in _instances)
|
||||
{
|
||||
<option value="@i.Id">@i.UniqueName</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label form-label-sm">Attribute</label>
|
||||
<select class="form-select form-select-sm" data-test="flow-attribute-select"
|
||||
@bind="_selectedAttribute" @bind:after="OnAttributeChangedAsync" disabled="@(_running || _selectedInstanceId == 0)">
|
||||
<option value="">— choose —</option>
|
||||
@foreach (var a in _attributes)
|
||||
{
|
||||
<option value="@a">@a</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label form-label-sm">Execution ID</label>
|
||||
<input class="form-control form-control-sm font-monospace" data-test="flow-execution-id"
|
||||
placeholder="any execution id in the chain" @bind="_replayExecutionIdText" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto">
|
||||
<label class="form-label form-label-sm">Mode</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Mode">
|
||||
<button type="button" class="btn @(_mode == FlowMode.Live ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-live" disabled="@_running" @onclick="() => _mode = FlowMode.Live">Live</button>
|
||||
<button type="button" class="btn @(_mode == FlowMode.Replay ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-mode-replay" disabled="@_running" @onclick="() => _mode = FlowMode.Replay">Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-auto">
|
||||
@if (_mode == FlowMode.Live)
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" data-test="flow-run"
|
||||
disabled="@(!CanRun)" @onclick="RunAsync">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||
}
|
||||
Run
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" data-test="flow-load"
|
||||
disabled="@(_running || !Guid.TryParse(_replayExecutionIdText, out _))" @onclick="LoadReplayAsync">Load from audit</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Inline alerts ───────────────────────────────────────────────────── *@
|
||||
@if (_error != null)
|
||||
{
|
||||
<div class="alert @(_errorCode == "STANDBY_NODE" ? "alert-warning" : "alert-danger") py-2 small"
|
||||
role="alert" data-test="flow-error" data-code="@_errorCode">
|
||||
@if (_errorCode != null)
|
||||
{
|
||||
<strong class="me-1">@_errorCode</strong>
|
||||
}
|
||||
@_error
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Status strip ─────────────────────────────────────────────────────── *@
|
||||
<div class="fd-status mb-3" data-test="flow-status">
|
||||
<span class="fd-chip @StateCss" data-test="flow-state">@StateLabel</span>
|
||||
@if (_runNumber > 0)
|
||||
{
|
||||
<span><span class="fd-label">Run</span><span class="fd-mono">#@_runNumber</span></span>
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
<span><span class="fd-label">Started</span><span class="fd-mono">@_raw.StartedAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff")Z</span></span>
|
||||
<span><span class="fd-label">Total</span><span class="fd-mono">@((int)(_raw.FinishedAtUtc - _raw.StartedAtUtc).TotalMilliseconds) ms</span></span>
|
||||
}
|
||||
@if (_graph != null)
|
||||
{
|
||||
<span><span class="fd-label">Steps</span><span class="fd-mono" data-test="flow-step-count">@_graph.Steps.Count</span></span>
|
||||
<span><span class="fd-label">Boundaries</span><span class="fd-mono">@_graph.Edges.Count(e => e.CrossesBoundary)</span></span>
|
||||
<span><span class="fd-label">Writes</span><span class="fd-mono">@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)</span></span>
|
||||
}
|
||||
@if (_raw != null)
|
||||
{
|
||||
<span>
|
||||
<span class="fd-label">Root</span>
|
||||
<a class="fd-mono" data-test="flow-root-execution" href="@($"/audit/execution-tree?executionId={_raw.RootExecutionId}")">@_raw.RootExecutionId</a>
|
||||
</span>
|
||||
}
|
||||
@if (_graph?.Truncated == true)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">event list truncated</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* ── Inputs (Live only) ────────────────────────────────────────────── *@
|
||||
@if (_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0))
|
||||
{
|
||||
<div class="col-lg-3">
|
||||
<div class="card fd-inputs" data-test="flow-inputs">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-muted">Inputs</h6>
|
||||
@if (_entry == EntryKind.Method)
|
||||
{
|
||||
@foreach (var f in _inputFields)
|
||||
{
|
||||
var field = f;
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">
|
||||
@field.Name
|
||||
<span class="text-muted">(@field.Schema.Type@(field.Required ? ", required" : ""))</span>
|
||||
</label>
|
||||
<input class="form-control form-control-sm" data-test="@("flow-input-" + field.Name)"
|
||||
value="@InputValue(field.Name)" @oninput="e => SetInput(field.Name, e.Value?.ToString())" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">New value for <span class="font-monospace">@(_selectedAttribute == "" ? "…" : _selectedAttribute)</span></label>
|
||||
<input class="form-control form-control-sm" data-test="flow-input-value"
|
||||
@bind="_valueChangeValue" @bind:event="oninput" disabled="@_running" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Flow panel ────────────────────────────────────────────────────── *@
|
||||
<div class="@(_mode == FlowMode.Live && (_entry == EntryKind.ValueChange || _inputFields.Count > 0) ? "col-lg-9" : "col-12")">
|
||||
<div class="fd-flow-tools">
|
||||
<div class="small text-muted">
|
||||
@if (_graph == null && _predicted != null)
|
||||
{
|
||||
<span data-test="flow-predicted-note">Predicted from script syntax — dashed nodes depend on inputs or live site state.</span>
|
||||
}
|
||||
else if (SiteNotTraced)
|
||||
{
|
||||
<span data-test="flow-site-not-traced">Site not traced — the site returned no trace rows for the routed call (older site build or an untraced call).</span>
|
||||
}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Max(0.4, _zoom - 0.1)" aria-label="Zoom out">−</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = 1.0" aria-label="Fit">Fit</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => _zoom = Math.Min(2.0, _zoom + 0.1)" aria-label="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowGraphView Graph="_graph" Predicted="_predicted" SelectedEventId="_selected?.EventId" OnSelect="OnSelectStep" Zoom="_zoom" />
|
||||
</div>
|
||||
|
||||
@* ── Detail panel ──────────────────────────────────────────────────── *@
|
||||
<div class="col-12">
|
||||
<FlowStepDetail Step="_selected" Graph="_graph"
|
||||
SiteDbId="@(_entry == EntryKind.ValueChange && _selectedSiteId > 0 ? _selectedSiteId : null)"
|
||||
InstanceDbId="@(_entry == EntryKind.ValueChange && _selectedInstanceId > 0 ? _selectedInstanceId : null)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+555
@@ -0,0 +1,555 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger page (design 2026-09-05 §4): pick an entry point (inbound API method or a
|
||||
/// value-change trigger), see the predicted swimlane, run it live with tracing on the active
|
||||
/// central node, or replay a past execution from the audit store. State machine
|
||||
/// <c>Idle → Predicted → Running → Completed | Failed | TimedOut</c>. The server's
|
||||
/// <c>STANDBY_NODE</c> refusal renders as an inline alert with the message verbatim.
|
||||
/// </summary>
|
||||
public partial class FlowDebugger : ComponentBase
|
||||
{
|
||||
/// <summary>Entry point kinds.</summary>
|
||||
public enum EntryKind
|
||||
{
|
||||
/// <summary>An inbound API method.</summary>
|
||||
Method,
|
||||
/// <summary>A value-change trigger on an instance attribute.</summary>
|
||||
ValueChange,
|
||||
}
|
||||
|
||||
/// <summary>Live vs Replay.</summary>
|
||||
public enum FlowMode
|
||||
{
|
||||
/// <summary>Run it now.</summary>
|
||||
Live,
|
||||
/// <summary>Rebuild a past flow from the audit store.</summary>
|
||||
Replay,
|
||||
}
|
||||
|
||||
/// <summary>Page state.</summary>
|
||||
public enum FlowState
|
||||
{
|
||||
/// <summary>Nothing chosen.</summary>
|
||||
Idle,
|
||||
/// <summary>Predicted graph shown, not yet run.</summary>
|
||||
Predicted,
|
||||
/// <summary>A run or replay is in flight.</summary>
|
||||
Running,
|
||||
/// <summary>Run finished (or replay loaded).</summary>
|
||||
Completed,
|
||||
/// <summary>Run refused or failed.</summary>
|
||||
Failed,
|
||||
/// <summary>Run timed out / no trigger within the window.</summary>
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
private const string StorageEntry = "flowDebugger.entry";
|
||||
private const string StorageMethod = "flowDebugger.methodId";
|
||||
private const string StorageSite = "flowDebugger.siteId";
|
||||
private const string StorageInstance = "flowDebugger.instanceId";
|
||||
private const string StorageAttribute = "flowDebugger.attribute";
|
||||
private const string StorageInputsPrefix = "flowDebugger.inputs.";
|
||||
|
||||
[Inject] private IFlowDebugService FlowDebug { get; set; } = default!;
|
||||
[Inject] private IInboundApiRepository InboundApi { get; set; } = default!;
|
||||
[Inject] private ITemplateEngineRepository TemplateEngineRepository { get; set; } = default!;
|
||||
[Inject] private ISiteRepository SiteRepository { get; set; } = default!;
|
||||
[Inject] private SiteScopeService SiteScope { get; set; } = default!;
|
||||
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||
|
||||
/// <summary>Drill-in: pre-select an inbound method.</summary>
|
||||
[SupplyParameterFromQuery] public int? MethodId { get; set; }
|
||||
|
||||
/// <summary>Drill-in: pre-select an instance (value-change entry).</summary>
|
||||
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
|
||||
|
||||
/// <summary>Drill-in: pre-select the trigger attribute (with <see cref="InstanceId"/>).</summary>
|
||||
[SupplyParameterFromQuery] public string? Attribute { get; set; }
|
||||
|
||||
/// <summary>Drill-in: replay this execution on first render.</summary>
|
||||
[SupplyParameterFromQuery] public Guid? ExecutionId { get; set; }
|
||||
|
||||
private EntryKind _entry = EntryKind.Method;
|
||||
private FlowMode _mode = FlowMode.Live;
|
||||
private FlowState _state = FlowState.Idle;
|
||||
|
||||
private List<ApiMethod> _methods = [];
|
||||
private List<Site> _sites = [];
|
||||
private List<Instance> _instances = [];
|
||||
private List<string> _attributes = [];
|
||||
|
||||
private int _selectedMethodId;
|
||||
private int _selectedSiteId;
|
||||
private int _selectedInstanceId;
|
||||
private string _selectedAttribute = string.Empty;
|
||||
private string _valueChangeValue = string.Empty;
|
||||
private string _replayExecutionIdText = string.Empty;
|
||||
|
||||
private List<InboundApiSchemaField> _inputFields = [];
|
||||
private readonly Dictionary<string, string> _inputs = new(StringComparer.Ordinal);
|
||||
|
||||
private PredictedFlow? _predicted;
|
||||
private FlowGraph? _graph;
|
||||
private FlowDebugRunResult? _raw;
|
||||
private FlowStep? _selected;
|
||||
private string? _error;
|
||||
private string? _errorCode;
|
||||
private int _runNumber;
|
||||
private double _zoom = 1.0;
|
||||
private bool _running;
|
||||
private bool _storageRestored;
|
||||
|
||||
private bool CanRun => !_running && _mode == FlowMode.Live && (
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0
|
||||
: _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute));
|
||||
|
||||
private string StateLabel => _state switch
|
||||
{
|
||||
FlowState.Idle => "Not run",
|
||||
FlowState.Predicted => "Predicted",
|
||||
FlowState.Running => "Running",
|
||||
FlowState.Completed => "Completed",
|
||||
FlowState.Failed => "Failed",
|
||||
FlowState.TimedOut => "Timed out",
|
||||
_ => _state.ToString(),
|
||||
};
|
||||
|
||||
private string StateCss => _state switch
|
||||
{
|
||||
FlowState.Idle => "idle",
|
||||
FlowState.Predicted => "predicted",
|
||||
FlowState.Running => "running",
|
||||
FlowState.Completed => "completed",
|
||||
FlowState.Failed => "failed",
|
||||
FlowState.TimedOut => "timed-out",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True when a run produced a route call whose site leg came back untraced — no Site-lane
|
||||
/// steps and no child execution id on the route call (older site build / untraced call).
|
||||
/// </summary>
|
||||
private bool SiteNotTraced
|
||||
=> _graph is { Steps.Count: > 0 }
|
||||
&& _graph.Steps.All(s => s.Lane != FlowLane.Site)
|
||||
&& _graph.Steps.Any(s => s.Kind == FlowStepKind.RouteCall && s.Extra?.ChildExecutionId == null);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
|
||||
if (InstanceId is int iid && iid > 0)
|
||||
{
|
||||
_entry = EntryKind.ValueChange;
|
||||
var inst = await TemplateEngineRepository.GetInstanceByIdAsync(iid);
|
||||
if (inst != null && await SiteScope.IsSiteAllowedAsync(inst.SiteId))
|
||||
{
|
||||
_selectedSiteId = inst.SiteId;
|
||||
await LoadInstancesAsync();
|
||||
_selectedInstanceId = inst.Id;
|
||||
await LoadAttributesAsync();
|
||||
if (!string.IsNullOrEmpty(Attribute) && _attributes.Contains(Attribute))
|
||||
{
|
||||
_selectedAttribute = Attribute;
|
||||
await PredictAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
|
||||
{
|
||||
_entry = EntryKind.Method;
|
||||
_selectedMethodId = mid;
|
||||
await OnMethodChangedAsync();
|
||||
}
|
||||
|
||||
if (ExecutionId is Guid exec && exec != Guid.Empty)
|
||||
{
|
||||
_mode = FlowMode.Replay;
|
||||
_replayExecutionIdText = exec.ToString();
|
||||
await LoadReplayAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _storageRestored)
|
||||
return;
|
||||
_storageRestored = true;
|
||||
|
||||
// Only restore from localStorage when nothing was chosen through the URL.
|
||||
if (_selectedMethodId > 0 || _selectedInstanceId > 0 || ExecutionId != null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var entry = await JS.InvokeAsync<string?>("localStorage.getItem", StorageEntry);
|
||||
if (string.Equals(entry, nameof(EntryKind.ValueChange), StringComparison.Ordinal))
|
||||
{
|
||||
_entry = EntryKind.ValueChange;
|
||||
var site = await JS.InvokeAsync<string?>("localStorage.getItem", StorageSite);
|
||||
var inst = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInstance);
|
||||
var attr = await JS.InvokeAsync<string?>("localStorage.getItem", StorageAttribute);
|
||||
if (int.TryParse(site, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sid) && _sites.Any(s => s.Id == sid))
|
||||
{
|
||||
_selectedSiteId = sid;
|
||||
await LoadInstancesAsync();
|
||||
if (int.TryParse(inst, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iid) && _instances.Any(i => i.Id == iid))
|
||||
{
|
||||
_selectedInstanceId = iid;
|
||||
await LoadAttributesAsync();
|
||||
if (!string.IsNullOrEmpty(attr) && _attributes.Contains(attr))
|
||||
{
|
||||
_selectedAttribute = attr;
|
||||
await PredictAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var method = await JS.InvokeAsync<string?>("localStorage.getItem", StorageMethod);
|
||||
if (int.TryParse(method, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mid) && _methods.Any(m => m.Id == mid))
|
||||
{
|
||||
_selectedMethodId = mid;
|
||||
await OnMethodChangedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// localStorage unavailable (prerender, test host) — start clean.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetEntryAsync(EntryKind entry)
|
||||
{
|
||||
if (_entry == entry) return;
|
||||
_entry = entry;
|
||||
ResetResult();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
await PersistAsync(StorageEntry, entry.ToString());
|
||||
if (_entry == EntryKind.Method && _selectedMethodId > 0)
|
||||
await OnMethodChangedAsync();
|
||||
else if (_entry == EntryKind.ValueChange && _selectedInstanceId > 0 && _selectedAttribute != string.Empty)
|
||||
await PredictAsync();
|
||||
}
|
||||
|
||||
private async Task OnMethodChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
_inputFields = [];
|
||||
_inputs.Clear();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
|
||||
var method = _methods.FirstOrDefault(m => m.Id == _selectedMethodId);
|
||||
if (method == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var schema = InboundApiSchema.Parse(method.ParameterDefinitions);
|
||||
if (schema is { Type: "object" })
|
||||
_inputFields = schema.Fields.ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_inputFields = [];
|
||||
}
|
||||
|
||||
await PersistAsync(StorageEntry, EntryKind.Method.ToString());
|
||||
await PersistAsync(StorageMethod, method.Id.ToString(CultureInfo.InvariantCulture));
|
||||
await RestoreInputsAsync(method.Id);
|
||||
await PredictAsync();
|
||||
}
|
||||
|
||||
private async Task OnSiteChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
_selectedInstanceId = 0;
|
||||
_selectedAttribute = string.Empty;
|
||||
_attributes = [];
|
||||
await LoadInstancesAsync();
|
||||
await PersistAsync(StorageSite, _selectedSiteId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private async Task OnInstanceChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
_predicted = null;
|
||||
_state = FlowState.Idle;
|
||||
_selectedAttribute = string.Empty;
|
||||
await LoadAttributesAsync();
|
||||
await PersistAsync(StorageInstance, _selectedInstanceId.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private async Task OnAttributeChangedAsync()
|
||||
{
|
||||
ResetResult();
|
||||
await PersistAsync(StorageEntry, EntryKind.ValueChange.ToString());
|
||||
await PersistAsync(StorageAttribute, _selectedAttribute);
|
||||
await PredictAsync();
|
||||
}
|
||||
|
||||
private async Task LoadInstancesAsync()
|
||||
{
|
||||
_instances = _selectedSiteId > 0
|
||||
? (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId)).OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task LoadAttributesAsync()
|
||||
{
|
||||
_attributes = [];
|
||||
var inst = _instances.FirstOrDefault(i => i.Id == _selectedInstanceId);
|
||||
if (inst == null)
|
||||
return;
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
||||
_attributes = attrs.Select(a => a.Name).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private async Task PredictAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_predicted = _entry == EntryKind.Method
|
||||
? await FlowDebug.PredictMethodAsync(_selectedMethodId)
|
||||
: await FlowDebug.PredictValueChangeAsync(_selectedInstanceId, _selectedAttribute);
|
||||
_state = _predicted.Nodes.Count > 0 ? FlowState.Predicted : FlowState.Idle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_predicted = null;
|
||||
_error = "Prediction failed: " + ex.Message;
|
||||
_errorCode = "PREDICTION_FAILED";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
if (!CanRun) return;
|
||||
_running = true;
|
||||
_state = FlowState.Running;
|
||||
_error = null;
|
||||
_errorCode = null;
|
||||
_graph = null;
|
||||
_raw = null;
|
||||
_selected = null;
|
||||
_runNumber++;
|
||||
|
||||
try
|
||||
{
|
||||
FlowDebugOutcome outcome;
|
||||
if (_entry == EntryKind.Method)
|
||||
{
|
||||
await PersistInputsAsync(_selectedMethodId);
|
||||
outcome = await FlowDebug.RunMethodAsync(_selectedMethodId, BuildParameters());
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = await FlowDebug.RunValueChangeAsync(_selectedInstanceId, _selectedAttribute, _valueChangeValue);
|
||||
}
|
||||
|
||||
ApplyOutcome(outcome);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
_errorCode = "RUN_FAILED";
|
||||
_state = FlowState.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadReplayAsync()
|
||||
{
|
||||
if (!Guid.TryParse(_replayExecutionIdText, out var exec)) return;
|
||||
_running = true;
|
||||
_state = FlowState.Running;
|
||||
_error = null;
|
||||
_errorCode = null;
|
||||
_graph = null;
|
||||
_raw = null;
|
||||
_selected = null;
|
||||
_predicted = null;
|
||||
|
||||
try
|
||||
{
|
||||
ApplyOutcome(await FlowDebug.ReplayAsync(exec));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
_errorCode = "REPLAY_FAILED";
|
||||
_state = FlowState.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyOutcome(FlowDebugOutcome outcome)
|
||||
{
|
||||
if (!outcome.Success || outcome.Graph == null)
|
||||
{
|
||||
_error = outcome.Error ?? "The run failed.";
|
||||
_errorCode = outcome.ErrorCode;
|
||||
_state = FlowState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
_graph = outcome.Graph;
|
||||
_raw = outcome.Raw;
|
||||
if (_raw is { Completed: false })
|
||||
{
|
||||
_error = _raw.Error;
|
||||
_errorCode = _raw.ErrorCode;
|
||||
_state = _raw.ErrorCode is "NO_TRIGGER_WITHIN_WINDOW" or "TIMEOUT" ? FlowState.TimedOut : FlowState.Failed;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _graph.Steps.Any(s => s.Outcome == FlowStepOutcome.TimedOut) ? FlowState.TimedOut
|
||||
: _graph.Steps.Any(s => s.Outcome == FlowStepOutcome.Failed) ? FlowState.Failed
|
||||
: FlowState.Completed;
|
||||
}
|
||||
|
||||
_selected = _graph.Steps.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void ResetResult()
|
||||
{
|
||||
_graph = null;
|
||||
_raw = null;
|
||||
_selected = null;
|
||||
_error = null;
|
||||
_errorCode = null;
|
||||
}
|
||||
|
||||
private void OnSelectStep(FlowStep step) => _selected = step;
|
||||
|
||||
private string InputValue(string name) => _inputs.TryGetValue(name, out var v) ? v : string.Empty;
|
||||
|
||||
private void SetInput(string name, string? value)
|
||||
{
|
||||
if (value == null) _inputs.Remove(name);
|
||||
else _inputs[name] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the request body from the typed inputs, coercing by the schema's declared field
|
||||
/// type (integers/numbers/booleans become JSON scalars; objects/lists are parsed as JSON;
|
||||
/// everything else is a string). Empty inputs are omitted so a non-required field is not
|
||||
/// sent as an empty string.
|
||||
/// </summary>
|
||||
internal JsonElement? BuildParameters()
|
||||
{
|
||||
if (_inputFields.Count == 0)
|
||||
return null;
|
||||
|
||||
var obj = new JsonObject();
|
||||
foreach (var f in _inputFields)
|
||||
{
|
||||
if (!_inputs.TryGetValue(f.Name, out var raw) || string.IsNullOrEmpty(raw))
|
||||
continue;
|
||||
obj[f.Name] = Coerce(f.Schema.Type, raw);
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(obj);
|
||||
}
|
||||
|
||||
private static JsonNode? Coerce(string? type, string raw)
|
||||
{
|
||||
switch (InboundApiSchema.NormalizeType(type))
|
||||
{
|
||||
case "integer":
|
||||
return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) ? JsonValue.Create(l) : JsonValue.Create(raw);
|
||||
case "number":
|
||||
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? JsonValue.Create(d) : JsonValue.Create(raw);
|
||||
case "boolean":
|
||||
return bool.TryParse(raw, out var b) ? JsonValue.Create(b) : JsonValue.Create(raw);
|
||||
case "object":
|
||||
case "array":
|
||||
case "list":
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(raw);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return JsonValue.Create(raw);
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistAsync(string key, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", key, value);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best-effort convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistInputsAsync(int methodId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture),
|
||||
JsonSerializer.Serialize(_inputs));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreInputsAsync(int methodId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await JS.InvokeAsync<string?>("localStorage.getItem", StorageInputsPrefix + methodId.ToString(CultureInfo.InvariantCulture));
|
||||
if (string.IsNullOrEmpty(json)) return;
|
||||
var saved = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
if (saved == null) return;
|
||||
foreach (var (k, v) in saved)
|
||||
if (_inputFields.Any(f => f.Name == k))
|
||||
_inputs[k] = v;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/* Flow Debugger page — toolbar / status strip / flow / detail (design 2026-09-05 §4). */
|
||||
|
||||
.fd-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.fd-status .fd-label {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.fd-status .fd-mono { font-family: var(--mono); }
|
||||
|
||||
.fd-chip {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fd-chip.idle { color: var(--ink-soft); }
|
||||
.fd-chip.predicted { color: var(--accent); border-color: var(--accent); border-style: dashed; }
|
||||
.fd-chip.running { color: var(--accent); border-color: var(--accent); }
|
||||
.fd-chip.completed { color: var(--ok); border-color: var(--ok); }
|
||||
.fd-chip.failed { color: var(--bs-danger, #c0392b); border-color: var(--bs-danger, #c0392b); }
|
||||
.fd-chip.timed-out { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
.fd-flow-tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.fd-inputs .form-control-sm { font-family: var(--mono); }
|
||||
|
||||
::deep .fsd-eyebrow {
|
||||
color: var(--ink-faint);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
::deep .fsd-pane {
|
||||
border: 1px solid var(--rule);
|
||||
border-top: none;
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
::deep .fsd-json {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user