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:
parent
d82738e17e
commit
d9aabfd00a
2 changed files with 448 additions and 53 deletions
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue