chain-studio/lib/pages/approvals.dart
flemming-it 060c8003d5 feat(studio): operator-mode pass — Store + audit detail + channel switcher (v0.10.0)
Turns Studio from read-only dashboard into operator console.
Pieces:

  * **New Store page** (sidebar destination #3): browses the
    hub's bundled store-index with query + category + status
    filters. Each card has an Install button that prompts for
    the `.fai` bundle source (URL or local path), ships it to
    HubAdmin.InstallModule, shows success / error in a progress
    dialog, refreshes the list.

  * **Audit drill-down**: every event row is now tappable;
    opens a modal with all LoggedEvent fields including the
    new `detail` JSON pretty-printed in a code block. KRITIS
    forensic story: every audit row → full structured detail
    in two clicks.

  * **Approvals payload preview**: shipped already; now
    pretty-prints JSON when the preview parses as such, plus
    a clearer `PAYLOAD PREVIEW` label. Reviewer identity
    defaults to `$USER@studio` instead of the hard-coded
    `studio-mvp` so the audit trail records who acted.

  * **Channel switcher in Settings dialog**: the dialog now
    pulls HubAdmin.ChannelStatus and renders one row per
    channel (local / dev / beta / production) with port,
    running indicator, active marker. A "Connect" button
    on a running channel sets Studio's endpoint to that
    channel's port and reconnects in one click.

Cmd+1..6 keyboard shortcuts updated for the six destinations.
Widget test asserts every destination renders.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 11:54:31 +02:00

294 lines
8.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
class ApprovalsPage extends StatefulWidget {
const ApprovalsPage({super.key});
@override
State<ApprovalsPage> createState() => _ApprovalsPageState();
}
class _ApprovalsPageState extends State<ApprovalsPage> {
late Future<List<PendingApproval>> _future;
// 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.
late final String _reviewer = _defaultReviewer();
static String _defaultReviewer() {
final user = Platform.environment['USER'] ??
Platform.environment['USERNAME'] ??
'studio';
return '$user@studio';
}
@override
void initState() {
super.initState();
_future = HubService.instance.pendingApprovals();
}
void _refresh() => setState(() {
_future = HubService.instance.pendingApprovals();
});
Future<void> _approve(PendingApproval a) async {
try {
await HubService.instance.approve(a.id, _reviewer);
_toast('approved · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_toast('approve failed: $e');
}
}
Future<void> _reject(PendingApproval a) async {
final reason = await _promptReason(context);
if (reason == null || reason.isEmpty) return;
try {
await HubService.instance.reject(a.id, _reviewer, reason);
_toast('rejected · ${a.flowName} ${a.stepId}');
_refresh();
} catch (e) {
_toast('reject failed: $e');
}
}
void _toast(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<String?> _promptReason(BuildContext context) async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reject approval'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Reason (recorded in audit log)',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: const Text('Reject'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: const Text('Approvals'),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 18),
tooltip: 'Reload',
onPressed: _refresh,
),
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),
);
},
);
},
),
);
}
}
class _ApprovalCard extends StatelessWidget {
final PendingApproval approval;
final VoidCallback onApprove;
final VoidCallback onReject;
const _ApprovalCard({
required this.approval,
required this.onApprove,
required this.onReject,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FaiCard(
accentTop: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FaiPill(
label: 'pending',
tone: FaiPillTone.warning,
icon: Icons.pending_outlined,
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Text(
'${approval.flowName} ${approval.stepId}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (approval.expiresAt != null)
FaiPill(
label: _expiresIn(approval.expiresAt!),
tone: FaiPillTone.neutral,
icon: Icons.schedule,
),
],
),
const SizedBox(height: FaiSpace.lg),
Text(
approval.prompt,
style: theme.textTheme.bodyLarge,
),
if (approval.showPreview != null) ...[
const SizedBox(height: FaiSpace.md),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'PAYLOAD PREVIEW',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_prettyPreview(approval.showPreview!),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
const SizedBox(height: FaiSpace.lg),
Row(
children: [
Text(
approval.id,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
OutlinedButton.icon(
onPressed: onReject,
icon: const Icon(Icons.close, size: 16),
label: const Text('Reject'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check, size: 16),
label: const Text('Approve'),
),
],
),
],
),
);
}
String _prettyPreview(String raw) {
if (raw.isEmpty) return raw;
try {
final dynamic parsed = const JsonDecoder().convert(raw);
return const JsonEncoder.withIndent(' ').convert(parsed);
} catch (_) {
return raw;
}
}
String _expiresIn(DateTime t) {
final remaining = t.difference(DateTime.now());
if (remaining.isNegative) return 'expired';
final minutes = remaining.inMinutes;
if (minutes < 60) return '${minutes}m';
final hours = remaining.inHours;
return '${hours}h';
}
}