feat(studio): Approvals page gains History tab

Pending tab keeps the existing card layout for actionable
approvals. New History tab shows decided rows (approved /
rejected / expired) as compact one-liners, color-coded by
status with reviewer + decided-at metadata. Click expands to
the full record (prompt, payload preview, reason, ids).

Closes the visibility gap from the recent fix: rejected
approvals were correctly written to the hash-chained audit
log but invisible to operators on the Approvals page itself.
HubService gains `listApprovalsRecords(statuses:)` returning
the decided-side fields the Studio wire previously dropped.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:33:00 +02:00
parent d82738e17e
commit d9aabfd00a
2 changed files with 448 additions and 53 deletions

View file

@ -15,8 +15,11 @@ class ApprovalsPage extends StatefulWidget {
State<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage> {
late Future<List<PendingApproval>> _future;
class _ApprovalsPageState extends State<ApprovalsPage>
with SingleTickerProviderStateMixin {
late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture;
// Reviewer identity recorded in the audit log. Defaults to
// the OS user (closest stable identity Studio has without an
// auth backend); operators can override it per session.
@ -32,14 +35,29 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
@override
void initState() {
super.initState();
_future = HubService.instance.pendingApprovals();
_tab = TabController(length: 2, vsync: this);
_refresh();
}
void _refresh() => setState(() {
_future = HubService.instance.pendingApprovals();
});
@override
void dispose() {
_tab.dispose();
super.dispose();
}
Future<void> _approve(PendingApproval a) async {
void _refresh() {
setState(() {
_pendingFuture = HubService.instance.listApprovalsRecords(
statuses: const ['pending'],
);
_historyFuture = HubService.instance.listApprovalsRecords(
statuses: const ['approved', 'rejected', 'expired'],
limit: 200,
);
});
}
Future<void> _approve(ApprovalRecord a) async {
try {
await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
@ -49,7 +67,7 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
}
}
Future<void> _reject(PendingApproval a) async {
Future<void> _reject(ApprovalRecord a) async {
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
try {
@ -105,6 +123,13 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Approvals'),
bottom: TabBar(
controller: _tab,
tabs: const [
Tab(text: 'Pending'),
Tab(text: 'History'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
@ -114,54 +139,138 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
const SizedBox(width: FaiSpace.sm),
],
),
body: FutureBuilder<List<PendingApproval>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: _refresh,
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => _approve(a),
onReject: () => _reject(a),
);
},
);
},
body: TabBarView(
controller: _tab,
children: [
_PendingList(
future: _pendingFuture,
onApprove: _approve,
onReject: _reject,
onRetry: _refresh,
),
_HistoryList(
future: _historyFuture,
onRetry: _refresh,
),
],
),
);
}
}
class _PendingList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final void Function(ApprovalRecord) onApprove;
final void Function(ApprovalRecord) onReject;
final VoidCallback onRetry;
const _PendingList({
required this.future,
required this.onApprove,
required this.onReject,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final pending = snapshot.data ?? [];
if (pending.isEmpty) {
return const FaiEmptyState(
icon: Icons.task_alt_outlined,
title: 'Inbox zero',
hint:
'All caught up. New `system.approval@^0` steps land here automatically.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: pending.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
itemBuilder: (context, i) {
final a = pending[i];
return _ApprovalCard(
approval: a,
onApprove: () => onApprove(a),
onReject: () => onReject(a),
);
},
);
},
);
}
}
class _HistoryList extends StatelessWidget {
final Future<List<ApprovalRecord>> future;
final VoidCallback onRetry;
const _HistoryList({required this.future, required this.onRetry});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<List<ApprovalRecord>>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return FaiEmptyState(
icon: Icons.cloud_off_outlined,
iconColor: theme.colorScheme.error,
title: 'Hub unreachable',
hint: 'Start the hub with `fai serve`.',
action: FilledButton.tonal(
onPressed: onRetry,
child: const Text('Retry'),
),
);
}
final decided = snapshot.data ?? [];
if (decided.isEmpty) {
return const FaiEmptyState(
icon: Icons.history,
title: 'No history yet',
hint:
'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(FaiSpace.xl),
itemCount: decided.length,
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) {
final a = decided[i];
return _HistoryRow(record: a);
},
);
},
);
}
}
class _ApprovalCard extends StatelessWidget {
final PendingApproval approval;
final ApprovalRecord approval;
final VoidCallback onApprove;
final VoidCallback onReject;
@ -181,7 +290,7 @@ class _ApprovalCard extends StatelessWidget {
children: [
Row(
children: [
FaiPill(
const FaiPill(
label: 'pending',
tone: FaiPillTone.warning,
icon: Icons.pending_outlined,
@ -208,7 +317,7 @@ class _ApprovalCard extends StatelessWidget {
approval.prompt,
style: theme.textTheme.bodyLarge,
),
if (approval.showPreview != null) ...[
if (approval.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Padding(
padding: const EdgeInsets.only(bottom: 4),
@ -230,7 +339,7 @@ class _ApprovalCard extends StatelessWidget {
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_prettyPreview(approval.showPreview!),
_prettyPreview(approval.payloadPreview!),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
@ -292,3 +401,218 @@ class _ApprovalCard extends StatelessWidget {
return '${hours}h';
}
}
/// Compact one-line entry for a decided approval. Click expands
/// the prompt, decision metadata, and payload preview in a
/// dialog. Keeps the History tab scannable when there are many
/// decisions.
class _HistoryRow extends StatelessWidget {
final ApprovalRecord record;
const _HistoryRow({required this.record});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _statusPresentation(record.status);
final decided = record.decidedAt;
final timeStr =
decided == null ? '' : _formatTimestamp(decided.toLocal());
return InkWell(
onTap: () => showDialog<void>(
context: context,
builder: (_) => _HistoryDialog(record: record),
),
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: FaiSpace.sm,
),
child: Row(
children: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.md),
SizedBox(
width: 150,
child: Text(
timeStr,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: Text(
'${record.flowName} ${record.stepId}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: FaiSpace.md),
if (record.decidedBy.isNotEmpty)
Text(
record.decidedBy,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
static (FaiPillTone, IconData, String) _statusPresentation(String status) {
switch (status) {
case 'approved':
return (FaiPillTone.success, Icons.check, 'approved');
case 'rejected':
return (FaiPillTone.danger, Icons.close, 'rejected');
case 'expired':
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
default:
return (FaiPillTone.neutral, Icons.help_outline, status);
}
}
}
class _HistoryDialog extends StatelessWidget {
final ApprovalRecord record;
const _HistoryDialog({required this.record});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (tone, icon, label) = _HistoryRow._statusPresentation(record.status);
return AlertDialog(
title: Row(
children: [
FaiPill(label: label, tone: tone, icon: icon),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
'${record.flowName} ${record.stepId}',
style: theme.textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 480),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (record.decidedAt != null)
_kv(theme, 'decided',
'${_formatTimestamp(record.decidedAt!.toLocal())} '
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
_kv(theme, 'created',
_formatTimestamp(record.createdAt.toLocal())),
if (record.reason.isNotEmpty)
_kv(theme, 'reason', record.reason),
const SizedBox(height: FaiSpace.md),
Text(
'PROMPT',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
SelectableText(
record.prompt,
style: theme.textTheme.bodyMedium,
),
if (record.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
'PAYLOAD PREVIEW',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
record.payloadPreview!,
style: FaiTheme.mono(size: 11),
),
),
],
const SizedBox(height: FaiSpace.md),
Text(
record.id,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
}
Widget _kv(ThemeData theme, String k, String v) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(
k,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
),
),
),
Expanded(
child: SelectableText(
v,
style: theme.textTheme.bodySmall,
),
),
],
),
);
}
}
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
/// the history row + dialog. Always renders in the operator's
/// local time zone ISO timestamps from the wire still appear
/// raw in the per-record dialog for cross-checking.
String _formatTimestamp(DateTime local) {
final yyyy = local.year.toString().padLeft(4, '0');
final mo = local.month.toString().padLeft(2, '0');
final dd = local.day.toString().padLeft(2, '0');
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 '$yyyy-$mo-$dd $hh:$mm:$ss';
}