feat(studio): live step-progress in flow-run dialog
Some checks are pending
Security / Security check (push) Waiting to run
Some checks are pending
Security / Security check (push) Waiting to run
The flow-run dialog showed a single spinner + "running…" label
for the whole run. For multi-step flows the operator had no
way to see *which* step was busy or how close the run was to
finishing.
Replace the spinner with a live step list driven by a
StreamEvents subscription:
✔ extract 0.41s
⏳ summarize
◻ notify
━━━━━━━━━━──── 35%
Mirrors the `fai run` CLI rendering — one shared visual
language across both surfaces. Steps appear in execution
order as the hub emits step.started events; check + duration
on completion; cross + first-line error on failure; pause
icon on approval gates.
Implementation:
- HubService.streamEvents(backfill, types) — new public
stream-facade method that wraps HubClient.streamEvents and
maps proto LoggedEvent → AuditEvent for the rest of Studio.
Subscribed with backfill=0 so the dialog only sees events
from this very run.
- _FlowRunDialogState.initState subscribes BEFORE submitting
the run, so the first step.started never gets lost in the
gRPC handshake gap.
- Two-layer filter on incoming events: same flow name AND
timestamp >= dialog open time. The timestamp gate is what
stops a previous run's tail-end from painting stale rows
if the user re-runs the same saved flow.
- _LiveStep + _LiveStepList — insertion-ordered map renders
rows in runtime execution order (not alphabetical), so
what the operator sees matches what the hub did.
Version 0.51.5 → 0.51.6.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
2b65cd771f
commit
ba1a1a4f06
4 changed files with 249 additions and 15 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
|
|
@ -814,10 +815,33 @@ class _FlowRunDialog extends StatefulWidget {
|
|||
|
||||
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||
late final Future<Map<String, FlowOutput>> _future;
|
||||
late final StreamSubscription<AuditEvent> _eventSub;
|
||||
late final DateTime _startedAt;
|
||||
// Insertion-ordered map so the rendered list mirrors the
|
||||
// order steps were actually triggered, matching what the
|
||||
// operator sees on the CLI side.
|
||||
final Map<String, _LiveStep> _liveSteps = <String, _LiveStep>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startedAt = DateTime.now();
|
||||
// Subscribe BEFORE submitting so the first `step.started`
|
||||
// event lands in our handler rather than the broadcast
|
||||
// channel's dead-letter buffer.
|
||||
_eventSub = HubService.instance
|
||||
.streamEvents(
|
||||
backfill: 0,
|
||||
types: const [
|
||||
'step.started',
|
||||
'step.completed',
|
||||
'step.failed',
|
||||
'step.awaiting_approval',
|
||||
'step.approved',
|
||||
'step.rejected',
|
||||
],
|
||||
)
|
||||
.listen(_onEvent);
|
||||
_future = HubService.instance.runSavedFlow(
|
||||
name: widget.flow.name,
|
||||
textInputs: widget.textInputs,
|
||||
|
|
@ -826,6 +850,57 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onEvent(AuditEvent e) {
|
||||
// Two-layer filter: same flow name, AND only events newer
|
||||
// than our subscribe-time. Without the timestamp gate,
|
||||
// backfill or stream re-deliveries from an earlier run of
|
||||
// the same flow would paint stale steps into this dialog.
|
||||
if (e.flowName != widget.flow.name) return;
|
||||
if (e.timestamp.isBefore(_startedAt)) return;
|
||||
final stepId = e.stepId;
|
||||
if (stepId == null || stepId.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
switch (e.type) {
|
||||
case 'step.started':
|
||||
case 'step.approved':
|
||||
_liveSteps[stepId] = _LiveStep(
|
||||
id: stepId,
|
||||
state: _StepState.running,
|
||||
);
|
||||
case 'step.completed':
|
||||
_liveSteps[stepId] = _LiveStep(
|
||||
id: stepId,
|
||||
state: _StepState.done,
|
||||
durationMs: e.durationMs,
|
||||
);
|
||||
case 'step.failed':
|
||||
_liveSteps[stepId] = _LiveStep(
|
||||
id: stepId,
|
||||
state: _StepState.error,
|
||||
error: e.error,
|
||||
);
|
||||
case 'step.awaiting_approval':
|
||||
_liveSteps[stepId] = _LiveStep(
|
||||
id: stepId,
|
||||
state: _StepState.awaitingApproval,
|
||||
);
|
||||
case 'step.rejected':
|
||||
_liveSteps[stepId] = _LiveStep(
|
||||
id: stepId,
|
||||
state: _StepState.error,
|
||||
error: 'rejected',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -857,19 +932,9 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
l.flowsRunning,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
return _LiveStepList(
|
||||
steps: _liveSteps.values.toList(growable: false),
|
||||
runningLabel: l.flowsRunning,
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
|
|
@ -947,6 +1012,144 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
|||
}
|
||||
}
|
||||
|
||||
enum _StepState { running, done, error, awaitingApproval }
|
||||
|
||||
class _LiveStep {
|
||||
final String id;
|
||||
final _StepState state;
|
||||
final int? durationMs;
|
||||
final String? error;
|
||||
|
||||
const _LiveStep({
|
||||
required this.id,
|
||||
required this.state,
|
||||
this.durationMs,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
|
||||
/// Live step list shown while a flow is running. Mirrors the
|
||||
/// `fai run` CLI rendering: empty box for pending steps the
|
||||
/// hub has hinted at but not started yet, a small spinner for
|
||||
/// the currently-running step, a check + duration for done
|
||||
/// steps, and a cross + first error line on failure. The list
|
||||
/// grows in insertion order so the operator sees the runtime
|
||||
/// order of execution, not a re-sorted view.
|
||||
class _LiveStepList extends StatelessWidget {
|
||||
final List<_LiveStep> steps;
|
||||
final String runningLabel;
|
||||
|
||||
const _LiveStepList({
|
||||
required this.steps,
|
||||
required this.runningLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Empty state (subscribe arrived first, no step has fired
|
||||
// yet) — show a small spinner + the localised "running"
|
||||
// label so the dialog doesn't look blank.
|
||||
if (steps.isEmpty) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
runningLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final done = steps
|
||||
.where((s) => s.state == _StepState.done || s.state == _StepState.error)
|
||||
.length;
|
||||
final pct = done * 100 ~/ steps.length;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final step in steps) _stepRow(context, step),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
LinearProgressIndicator(
|
||||
value: pct / 100,
|
||||
minHeight: 4,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$pct%',
|
||||
style: FaiTheme.mono(
|
||||
size: 10,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(BuildContext context, _LiveStep step) {
|
||||
final theme = Theme.of(context);
|
||||
final Widget glyph;
|
||||
final String suffix;
|
||||
Color color = theme.colorScheme.onSurface;
|
||||
switch (step.state) {
|
||||
case _StepState.running:
|
||||
glyph = const SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
suffix = '';
|
||||
case _StepState.done:
|
||||
glyph = Icon(Icons.check, size: 16, color: theme.colorScheme.primary);
|
||||
final s = (step.durationMs ?? 0) / 1000.0;
|
||||
suffix = ' ${s.toStringAsFixed(2)}s';
|
||||
case _StepState.error:
|
||||
glyph = Icon(Icons.close, size: 16, color: theme.colorScheme.error);
|
||||
suffix = step.error == null || step.error!.isEmpty
|
||||
? ''
|
||||
: ' — ${step.error!.split('\n').first.trim()}';
|
||||
color = theme.colorScheme.error;
|
||||
case _StepState.awaitingApproval:
|
||||
glyph = Icon(
|
||||
Icons.pause_circle_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.tertiary,
|
||||
);
|
||||
suffix = ' awaiting approval';
|
||||
color = theme.colorScheme.tertiary;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 16, child: Center(child: glyph)),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${step.id}$suffix',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-spec install state for the dependencies dialog. The
|
||||
/// dialog walks the list sequentially, advancing each row from
|
||||
/// `pending` → `installing` → `done` | `failed` so the operator
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue