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
|
|
@ -729,6 +729,37 @@ class HubService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live audit-event stream. The hub's StreamEvents RPC
|
||||||
|
/// optionally replays [backfill] historical events first, then
|
||||||
|
/// keeps the channel open and forwards every new event as soon
|
||||||
|
/// as it lands. Filtering by [types] is applied on the hub
|
||||||
|
/// side so this surface stays lean even when the log is busy.
|
||||||
|
///
|
||||||
|
/// Subscribe **before** triggering the work whose progress you
|
||||||
|
/// want to follow — otherwise the earliest `*.started` events
|
||||||
|
/// can arrive before the subscriber is ready and slip past.
|
||||||
|
Stream<AuditEvent> streamEvents({
|
||||||
|
int backfill = 0,
|
||||||
|
List<String> types = const [],
|
||||||
|
}) {
|
||||||
|
return _client.streamEvents(backfill: backfill, types: types).map(
|
||||||
|
(e) => AuditEvent(
|
||||||
|
eventId: e.eventId,
|
||||||
|
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
|
||||||
|
type: e.eventType,
|
||||||
|
flowName: e.flowName.isEmpty ? null : e.flowName,
|
||||||
|
stepId: e.stepId.isEmpty ? null : e.stepId,
|
||||||
|
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
||||||
|
moduleVersion: e.moduleVersion.isEmpty ? null : e.moduleVersion,
|
||||||
|
invocationId: e.invocationId.isEmpty ? null : e.invocationId,
|
||||||
|
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
||||||
|
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
||||||
|
error: e.error.isEmpty ? null : e.error,
|
||||||
|
detail: e.detail.isEmpty ? null : e.detail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<AuditEvent>> recentEvents({
|
Future<List<AuditEvent>> recentEvents({
|
||||||
int limit = 50,
|
int limit = 50,
|
||||||
List<String> types = const [],
|
List<String> types = const [],
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.51.5';
|
const String kStudioVersion = '0.51.6';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
|
@ -814,10 +815,33 @@ class _FlowRunDialog extends StatefulWidget {
|
||||||
|
|
||||||
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
late final Future<Map<String, FlowOutput>> _future;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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(
|
_future = HubService.instance.runSavedFlow(
|
||||||
name: widget.flow.name,
|
name: widget.flow.name,
|
||||||
textInputs: widget.textInputs,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -857,19 +932,9 @@ class _FlowRunDialogState extends State<_FlowRunDialog> {
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return Column(
|
return _LiveStepList(
|
||||||
mainAxisSize: MainAxisSize.min,
|
steps: _liveSteps.values.toList(growable: false),
|
||||||
children: [
|
runningLabel: l.flowsRunning,
|
||||||
const SizedBox(height: FaiSpace.md),
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
const SizedBox(height: FaiSpace.md),
|
|
||||||
Text(
|
|
||||||
l.flowsRunning,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
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
|
/// Per-spec install state for the dependencies dialog. The
|
||||||
/// dialog walks the list sequentially, advancing each row from
|
/// dialog walks the list sequentially, advancing each row from
|
||||||
/// `pending` → `installing` → `done` | `failed` so the operator
|
/// `pending` → `installing` → `done` | `failed` so the operator
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.51.5
|
version: 0.51.6
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue