feat(studio): MCP suggestions + Approvals/Audit/Settings i18n (v0.26.0)
- Add curated catalogue of 9 official Anthropic MCP servers as click-to-prefill chips in the Add-MCP-server dialog. Operator still confirms via "Add + discover"; nothing is auto-spawned, preserving the regulated-environment trust model. - Localize Approvals page: pending/history empty states, status pills, payload preview, approve/reject toasts, reject dialog, history detail dialog, expires-in pill. - Localize Audit page: filter chips, clear-log dialog and toasts, no-events / hub-unreachable empty states, live-status bar with pluralized event count, hash-chain-verified badge, System AI panel (cached pill, latency, regenerate tooltip, asking state), event-detail dialog actions. - Localize Settings dialog: hub-endpoint section, channel blurb + popup-menu items, System AI panel headers and off-blurb, MCP/N8N panel headers + pluralized counts + refresh/add/remove tooltips, Add-MCP and Add-n8n dialogs (intro, suggestions label, all field labels and hints, save buttons, toasts). Field labels that map directly to JSON keys (event_id, flow, step, module, provider, endpoint, model) stay English by design — they're technical identifiers, not UI copy. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
349619d68e
commit
d25b4c87ae
9 changed files with 2022 additions and 209 deletions
|
|
@ -59,24 +59,26 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
}
|
||||
|
||||
Future<void> _approve(ApprovalRecord a) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
_toast('approved · ${a.flowName} › ${a.stepId}');
|
||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
_toast('approve failed: $e');
|
||||
_toast(l.approvalsApproveFailed(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reject(ApprovalRecord a) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
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}');
|
||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
_toast('reject failed: $e');
|
||||
_toast(l.approvalsRejectFailed(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,22 +89,23 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
|
||||
Future<String?> _promptReason(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reject approval'),
|
||||
title: Text(l.approvalsRejectDialogTitle),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reason (recorded in audit log)',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.approvalsRejectReasonLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
|
|
@ -110,7 +113,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Reject'),
|
||||
child: Text(l.approvalsRejectButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -134,7 +137,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Reload',
|
||||
tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip,
|
||||
onPressed: _refresh,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
|
|
@ -175,6 +178,7 @@ class _PendingList extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FutureBuilder<List<ApprovalRecord>>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -185,21 +189,20 @@ class _PendingList extends StatelessWidget {
|
|||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
final pending = snapshot.data ?? [];
|
||||
if (pending.isEmpty) {
|
||||
return const FaiEmptyState(
|
||||
return FaiEmptyState(
|
||||
icon: Icons.task_alt_outlined,
|
||||
title: 'Inbox zero',
|
||||
hint:
|
||||
'All caught up. New `system.approval@^0` steps land here automatically.',
|
||||
title: l.approvalsInboxZero,
|
||||
hint: l.approvalsInboxHint,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
|
|
@ -229,6 +232,7 @@ class _HistoryList extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FutureBuilder<List<ApprovalRecord>>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -239,21 +243,20 @@ class _HistoryList extends StatelessWidget {
|
|||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
final decided = snapshot.data ?? [];
|
||||
if (decided.isEmpty) {
|
||||
return const FaiEmptyState(
|
||||
return 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.',
|
||||
title: l.approvalsHistoryEmpty,
|
||||
hint: l.approvalsHistoryEmptyHint,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
|
|
@ -284,6 +287,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FaiCard(
|
||||
accentTop: true,
|
||||
child: Column(
|
||||
|
|
@ -291,8 +295,8 @@ class _ApprovalCard extends StatelessWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const FaiPill(
|
||||
label: 'pending',
|
||||
FaiPill(
|
||||
label: l.approvalsPillPending,
|
||||
tone: FaiPillTone.warning,
|
||||
icon: Icons.pending_outlined,
|
||||
),
|
||||
|
|
@ -307,7 +311,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
),
|
||||
if (approval.expiresAt != null)
|
||||
FaiPill(
|
||||
label: _expiresIn(approval.expiresAt!),
|
||||
label: _expiresInLabel(context, approval.expiresAt!),
|
||||
tone: FaiPillTone.neutral,
|
||||
icon: Icons.schedule,
|
||||
),
|
||||
|
|
@ -323,7 +327,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'PAYLOAD PREVIEW',
|
||||
l.approvalsPayloadPreview,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -362,7 +366,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
OutlinedButton.icon(
|
||||
onPressed: onReject,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
label: const Text('Reject'),
|
||||
label: Text(l.approvalsRejectButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
|
|
@ -374,7 +378,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
FilledButton.icon(
|
||||
onPressed: onApprove,
|
||||
icon: const Icon(Icons.check, size: 16),
|
||||
label: const Text('Approve'),
|
||||
label: Text(l.approvalsApproveButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -393,13 +397,13 @@ class _ApprovalCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
String _expiresIn(DateTime t) {
|
||||
String _expiresInLabel(BuildContext context, DateTime t) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final remaining = t.difference(DateTime.now());
|
||||
if (remaining.isNegative) return 'expired';
|
||||
if (remaining.isNegative) return l.approvalsPillExpired;
|
||||
final minutes = remaining.inMinutes;
|
||||
if (minutes < 60) return '${minutes}m';
|
||||
final hours = remaining.inHours;
|
||||
return '${hours}h';
|
||||
if (minutes < 60) return l.approvalsExpiresIn(minutes);
|
||||
return l.approvalsExpiresInHours(remaining.inHours);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +418,7 @@ class _HistoryRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) = _statusPresentation(record.status);
|
||||
final (tone, icon, label) = _statusPresentation(context, record.status);
|
||||
final decided = record.decidedAt;
|
||||
final timeStr =
|
||||
decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||||
|
|
@ -467,14 +471,22 @@ class _HistoryRow extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
static (FaiPillTone, IconData, String) _statusPresentation(String status) {
|
||||
static (FaiPillTone, IconData, String) _statusPresentation(
|
||||
BuildContext context,
|
||||
String status,
|
||||
) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return (FaiPillTone.success, Icons.check, 'approved');
|
||||
return (FaiPillTone.success, Icons.check, l.approvalsPillApproved);
|
||||
case 'rejected':
|
||||
return (FaiPillTone.danger, Icons.close, 'rejected');
|
||||
return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected);
|
||||
case 'expired':
|
||||
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
|
||||
return (
|
||||
FaiPillTone.neutral,
|
||||
Icons.timer_off_outlined,
|
||||
l.approvalsPillExpired,
|
||||
);
|
||||
default:
|
||||
return (FaiPillTone.neutral, Icons.help_outline, status);
|
||||
}
|
||||
|
|
@ -488,7 +500,8 @@ class _HistoryDialog extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) = _HistoryRow._statusPresentation(record.status);
|
||||
final (tone, icon, label) =
|
||||
_HistoryRow._statusPresentation(context, record.status);
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
|
|
@ -511,16 +524,17 @@ class _HistoryDialog extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (record.decidedAt != null)
|
||||
_kv(theme, 'decided',
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||||
_kv(theme, 'created',
|
||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||
_formatTimestamp(record.createdAt.toLocal())),
|
||||
if (record.reason.isNotEmpty)
|
||||
_kv(theme, 'reason', record.reason),
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason,
|
||||
record.reason),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
'PROMPT',
|
||||
AppLocalizations.of(context)!.approvalsDialogPrompt,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -534,7 +548,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
if (record.payloadPreview != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
'PAYLOAD PREVIEW',
|
||||
AppLocalizations.of(context)!.approvalsPayloadPreview,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -570,7 +584,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(AppLocalizations.of(context)!.buttonClose),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue