feat(studio): operator QoL — flow runnability, audit grouping, batch approvals, welcome celebration (v0.42.0)
Four UX threads stitched into one commit. Each pulls Studio toward Stefan's "zero-learning-curve" goal — the feedback that earned its own memory entry. 1. Flow-Runnability-Indikator ───────────────────────── The Flows tab now fetches `listFlows` and `listModules` in parallel. Each card compares the flow's `requiredCapabilities` against the installed-modules' capability set; rows with missing modules show a "Needs: text.extract@^0" red pill row beneath the path and have their Run button greyed out + tooltip "Install the missing modules first." Operators stop hitting Run → cryptic hub error → frustration. 2. Welcome-Checklist Celebration ───────────────────────────── Once all four checklist signals flip to done, an `_AllDoneCelebration` card replaces the bare "All four steps complete" + Hide button. Three concrete next-threads with action buttons: "Read the audit log", "Set up the daily Today story" (opens the Flows / Today doc inline via `_DocReaderSheet`), and "Build your own module" (opens the architecture doc). Operator who just got set up sees what to do next instead of an empty "what now?" feeling. 3. Audit-Page Time-Bucket Headers + Flow-Run Detail ──────────────────────────────────────────────── The flat event list grows tiny "TODAY / YESTERDAY / EARLIER THIS WEEK / OLDER" section headers — bucket is computed in the operator's local timezone so an event at 23:55 yesterday in Berlin doesn't end up in "today" because UTC happened to spill into a new day. Plus: the event-detail dialog gains a "View flow run" action when the picked event has a `flow_execution`. It opens a drill-down that lists every event in the already-fetched 100-event window sharing the same execution id, sorted ascending — the operator reads the run from step.started top to flow.completed bottom. 4. Approvals-Batch-Aktionen ──────────────────────── Each pending approval card grows a checkbox. When ≥1 selected, a floating action bar appears at the bottom with "N selected · Select all · Clear · Reject all · Approve all". The parent loops sequentially through the per-record SDK calls so a partial failure produces "X done, Y failed" instead of a confusing all-or-nothing rollback. Reject prompts for a reason once and applies to the whole picked set. 13 new ARB keys cover the strings the four features needed. Studio's tests stay green. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
f90d8cc7a7
commit
2002486828
13 changed files with 1215 additions and 64 deletions
|
|
@ -137,27 +137,12 @@ class _AuditPageState extends State<AuditPage> {
|
|||
hint:
|
||||
AppLocalizations.of(context)!.auditNoEventsHint,
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
itemBuilder: (context, i) {
|
||||
final e = filtered[i];
|
||||
return FaiDataRow(
|
||||
accent: _toneFor(e.type, theme),
|
||||
leading: _formatTime(e.timestamp),
|
||||
title: e.type,
|
||||
subtitle: _contextLine(e),
|
||||
trailing: e.durationMs != null
|
||||
? '${e.durationMs}ms'
|
||||
: null,
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _EventDetailDialog(event: e),
|
||||
),
|
||||
);
|
||||
},
|
||||
: _GroupedEventList(
|
||||
events: filtered,
|
||||
allEvents: _events,
|
||||
toneFor: (t) => _toneFor(t, theme),
|
||||
formatTime: _formatTime,
|
||||
contextLine: _contextLine,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -208,6 +193,129 @@ class _AuditPageState extends State<AuditPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Audit list with time-bucket section headers. Walks the
|
||||
/// already-filtered events once and inserts a tiny header row
|
||||
/// whenever the relative-day bucket changes (Today / Yesterday
|
||||
/// / Earlier this week / Older). Reads the same events twice
|
||||
/// — one filtered slice for the visible rows, the full set
|
||||
/// for the per-flow-execution detail view triggered from the
|
||||
/// event-detail dialog.
|
||||
class _GroupedEventList extends StatelessWidget {
|
||||
final List<AuditEvent> events;
|
||||
final List<AuditEvent> allEvents;
|
||||
final Color Function(String type) toneFor;
|
||||
final String Function(DateTime ts) formatTime;
|
||||
final String Function(AuditEvent e) contextLine;
|
||||
|
||||
const _GroupedEventList({
|
||||
required this.events,
|
||||
required this.allEvents,
|
||||
required this.toneFor,
|
||||
required this.formatTime,
|
||||
required this.contextLine,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final items = _itemsWithHeaders(events, l);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
if (item is _GroupHeader) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: i == 0 ? 0 : FaiSpace.lg,
|
||||
bottom: FaiSpace.sm,
|
||||
),
|
||||
child: Text(
|
||||
item.label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final e = (item as _EventItem).event;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: FaiSpace.xs),
|
||||
child: FaiDataRow(
|
||||
accent: toneFor(e.type),
|
||||
leading: formatTime(e.timestamp),
|
||||
title: e.type,
|
||||
subtitle: contextLine(e),
|
||||
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _EventDetailDialog(
|
||||
event: e,
|
||||
allEvents: allEvents,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<_ListItem> _itemsWithHeaders(
|
||||
List<AuditEvent> events,
|
||||
AppLocalizations l,
|
||||
) {
|
||||
final out = <_ListItem>[];
|
||||
String? lastBucket;
|
||||
final now = DateTime.now();
|
||||
for (final e in events) {
|
||||
final bucket = _bucketLabel(e.timestamp, now, l);
|
||||
if (bucket != lastBucket) {
|
||||
out.add(_GroupHeader(bucket));
|
||||
lastBucket = bucket;
|
||||
}
|
||||
out.add(_EventItem(e));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Day-bucket label. Comparing in the operator's local
|
||||
/// timezone so an event at 23:55 yesterday in Berlin doesn't
|
||||
/// land in "today" because UTC happened to spill into a new
|
||||
/// day.
|
||||
static String _bucketLabel(
|
||||
DateTime ts,
|
||||
DateTime now,
|
||||
AppLocalizations l,
|
||||
) {
|
||||
final local = ts.toLocal();
|
||||
final localNow = now.toLocal();
|
||||
final today = DateTime(localNow.year, localNow.month, localNow.day);
|
||||
final eventDay = DateTime(local.year, local.month, local.day);
|
||||
final daysAgo = today.difference(eventDay).inDays;
|
||||
if (daysAgo <= 0) return l.auditGroupToday;
|
||||
if (daysAgo == 1) return l.auditGroupYesterday;
|
||||
if (daysAgo <= 6) return l.auditGroupThisWeek;
|
||||
return l.auditGroupOlder;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class _ListItem {
|
||||
const _ListItem();
|
||||
}
|
||||
|
||||
class _GroupHeader extends _ListItem {
|
||||
final String label;
|
||||
const _GroupHeader(this.label);
|
||||
}
|
||||
|
||||
class _EventItem extends _ListItem {
|
||||
final AuditEvent event;
|
||||
const _EventItem(this.event);
|
||||
}
|
||||
|
||||
class _FilterChips extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
|
@ -370,8 +478,17 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
|
||||
class _EventDetailDialog extends StatefulWidget {
|
||||
final AuditEvent event;
|
||||
/// Full event window the audit page already fetched. Lets
|
||||
/// the dialog surface every event sharing this event's
|
||||
/// `flow_execution` without a fresh round-trip — flow runs
|
||||
/// fit comfortably inside the 100-event window the audit
|
||||
/// page polls on.
|
||||
final List<AuditEvent> allEvents;
|
||||
|
||||
const _EventDetailDialog({required this.event});
|
||||
const _EventDetailDialog({
|
||||
required this.event,
|
||||
required this.allEvents,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EventDetailDialog> createState() => _EventDetailDialogState();
|
||||
|
|
@ -549,6 +666,22 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
),
|
||||
),
|
||||
actions: [
|
||||
if (event.flowExecution != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _FlowRunDialog(
|
||||
flowExecution: event.flowExecution!,
|
||||
flowName: event.flowName ?? event.flowExecution!,
|
||||
allEvents: widget.allEvents,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.account_tree_outlined, size: 16),
|
||||
label: Text(l.auditEventViewFlowRun),
|
||||
),
|
||||
if (_aiStatus?.enabled == true && (event.error != null))
|
||||
OutlinedButton.icon(
|
||||
onPressed: _explaining ? null : _explain,
|
||||
|
|
@ -579,6 +712,106 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Filtered drill-down: shows every event in `allEvents`
|
||||
/// whose `flowExecution` matches the picked one. Sorted by
|
||||
/// timestamp ascending so the operator reads the run from
|
||||
/// step.started top → flow.completed bottom. Read-only — the
|
||||
/// individual event-detail dialog is not re-opened from here
|
||||
/// to avoid recursive flow-run lookups; selecting a row in a
|
||||
/// future iteration could surface the same Explain UI.
|
||||
class _FlowRunDialog extends StatelessWidget {
|
||||
final String flowExecution;
|
||||
final String flowName;
|
||||
final List<AuditEvent> allEvents;
|
||||
|
||||
const _FlowRunDialog({
|
||||
required this.flowExecution,
|
||||
required this.flowName,
|
||||
required this.allEvents,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final related = allEvents
|
||||
.where((e) => e.flowExecution == flowExecution)
|
||||
.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.75;
|
||||
return AlertDialog(
|
||||
title: Text(l.auditFlowRunDialogTitle(flowName)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640, maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.auditFlowRunDialogSubtitle(related.length, flowExecution),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: related.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
itemBuilder: (context, i) {
|
||||
final e = related[i];
|
||||
return FaiDataRow(
|
||||
accent: _toneFor(e.type, theme),
|
||||
leading: _formatRelativeTime(e.timestamp),
|
||||
title: e.type,
|
||||
subtitle: _stepLine(e),
|
||||
trailing:
|
||||
e.durationMs != null ? '${e.durationMs}ms' : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l.buttonClose),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRelativeTime(DateTime ts) {
|
||||
final local = ts.toLocal();
|
||||
final hh = local.hour.toString().padLeft(2, '0');
|
||||
final mm = local.minute.toString().padLeft(2, '0');
|
||||
final ss = local.second.toString().padLeft(2, '0');
|
||||
return '$hh:$mm:$ss';
|
||||
}
|
||||
|
||||
String _stepLine(AuditEvent e) {
|
||||
final parts = <String>[];
|
||||
if (e.stepId != null) parts.add(e.stepId!);
|
||||
if (e.moduleName != null) parts.add('via ${e.moduleName}');
|
||||
if (e.error != null) parts.add('[error: ${e.error}]');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
Color _toneFor(String type, ThemeData theme) {
|
||||
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||||
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||||
if (type.endsWith('.started')) return FaiColors.warning;
|
||||
return theme.colorScheme.outline;
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue