diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 58c1ecc..8dd28c6 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -388,6 +388,53 @@ class HubService { .toList(); } + /// Snapshot of every configured n8n endpoint. + Future> listN8nEndpoints() async { + final r = await _client.listN8nEndpoints(); + return _mapN8nEndpoints(r); + } + + Future> refreshN8nEndpoints() async { + final r = await _client.refreshN8nEndpoints(); + return _mapN8nEndpoints(r); + } + + Future> addN8nEndpoint({ + required String name, + required String baseUrl, + String apiKeyEnv = '', + String description = '', + }) async { + final r = await _client.addN8nEndpoint( + name: name, + baseUrl: baseUrl, + apiKeyEnv: apiKeyEnv, + description: description, + ); + return _mapN8nEndpoints(r); + } + + Future> removeN8nEndpoint(String name) async { + final r = await _client.removeN8nEndpoint(name); + return _mapN8nEndpoints(r); + } + + List _mapN8nEndpoints(ListN8nEndpointsResponse r) { + return r.endpoints + .map( + (s) => N8nEndpointInfo( + name: s.config.name, + baseUrl: s.config.baseUrl, + apiKeyEnv: s.config.apiKeyEnv, + description: s.config.description, + workflowCount: s.workflowCount, + errorKind: s.errorKind, + message: s.message, + ), + ) + .toList(); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); @@ -975,6 +1022,30 @@ class ChannelStatusSnapshot { }); } +/// One configured n8n endpoint plus the last-discovery +/// report. Sister of [`McpClientInfo`]. +class N8nEndpointInfo { + final String name; + final String baseUrl; + final String apiKeyEnv; + final String description; + final int workflowCount; + final String errorKind; + final String message; + + const N8nEndpointInfo({ + required this.name, + required this.baseUrl, + required this.apiKeyEnv, + required this.description, + required this.workflowCount, + required this.errorKind, + required this.message, + }); + + bool get healthy => errorKind.isEmpty && workflowCount > 0; +} + /// One configured MCP server plus the last-discovery report. /// Surfaced by Studio's MCP-clients editor in Settings. class McpClientInfo { diff --git a/lib/main.dart b/lib/main.dart index 50d587c..645bada 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.22.0'; +const String kStudioVersion = '0.23.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index a74fcf8..e5cfe3d 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -38,6 +38,7 @@ class _FaiSettingsDialogState extends State { ChannelStatusSnapshot? _channels; SystemAiStatus? _aiStatus; List? _mcpClients; + List? _n8nEndpoints; @override void initState() { @@ -49,6 +50,62 @@ class _FaiSettingsDialogState extends State { _loadChannels(); _loadAiStatus(); _loadMcpClients(); + _loadN8nEndpoints(); + } + + Future _loadN8nEndpoints() async { + try { + final list = await HubService.instance.listN8nEndpoints(); + if (!mounted) return; + setState(() => _n8nEndpoints = list); + } catch (_) {/* fail-quiet */} + } + + Future _addN8nEndpoint(N8nEndpointDraft draft) async { + try { + final list = await HubService.instance.addN8nEndpoint( + name: draft.name, + baseUrl: draft.baseUrl, + apiKeyEnv: draft.apiKeyEnv, + description: draft.description, + ); + if (!mounted) return; + setState(() => _n8nEndpoints = list); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added n8n endpoint "${draft.name}".')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Add failed: $e')), + ); + } + } + + Future _removeN8nEndpoint(String name) async { + try { + final list = await HubService.instance.removeN8nEndpoint(name); + if (!mounted) return; + setState(() => _n8nEndpoints = list); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Remove failed: $e')), + ); + } + } + + Future _refreshN8nEndpoints() async { + try { + final list = await HubService.instance.refreshN8nEndpoints(); + if (!mounted) return; + setState(() => _n8nEndpoints = list); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Refresh failed: $e')), + ); + } } Future _loadMcpClients() async { @@ -391,6 +448,15 @@ class _FaiSettingsDialogState extends State { onRefresh: _refreshMcpClients, ), ], + if (_n8nEndpoints != null) ...[ + const SizedBox(height: FaiSpace.lg), + _N8nEndpointsPanel( + endpoints: _n8nEndpoints!, + onAdd: _addN8nEndpoint, + onRemove: _removeN8nEndpoint, + onRefresh: _refreshN8nEndpoints, + ), + ], ], ), ), @@ -982,3 +1048,298 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> { ); } } + +class N8nEndpointDraft { + final String name; + final String baseUrl; + final String apiKeyEnv; + final String description; + const N8nEndpointDraft({ + required this.name, + required this.baseUrl, + required this.apiKeyEnv, + required this.description, + }); +} + +/// n8n-endpoints panel — sister of `_McpClientsPanel`. Shows +/// every configured n8n install with last-discovery health +/// (workflow count or error_kind), plus Add / Remove / Refresh. +class _N8nEndpointsPanel extends StatelessWidget { + final List endpoints; + final ValueChanged onAdd; + final ValueChanged onRemove; + final VoidCallback onRefresh; + + const _N8nEndpointsPanel({ + required this.endpoints, + required this.onAdd, + required this.onRemove, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'N8N ENDPOINTS', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: + '${endpoints.length} endpoint${endpoints.length == 1 ? "" : "s"}', + tone: FaiPillTone.neutral, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 16), + tooltip: 'Re-run discovery', + visualDensity: VisualDensity.compact, + onPressed: onRefresh, + ), + IconButton( + icon: const Icon(Icons.add, size: 16), + tooltip: 'Add n8n endpoint', + visualDensity: VisualDensity.compact, + onPressed: () async { + final draft = await _AddN8nEndpointDialog.show(context); + if (draft != null) onAdd(draft); + }, + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Active n8n workflows surface as `n8n..` capabilities. ' + 'Configure once, see them in the Store.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), + if (endpoints.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: Text( + 'No n8n endpoints configured. Click + to add one.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + for (final e in endpoints) + _N8nEndpointRow( + endpoint: e, + onRemove: () => onRemove(e.name), + ), + ], + ); + } +} + +class _N8nEndpointRow extends StatelessWidget { + final N8nEndpointInfo endpoint; + final VoidCallback onRemove; + const _N8nEndpointRow({required this.endpoint, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ok = endpoint.healthy; + final dotColor = endpoint.errorKind.isEmpty + ? (ok ? FaiColors.success : FaiColors.muted) + : theme.colorScheme.error; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 6), + child: Icon(Icons.circle, size: 8, color: dotColor), + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + endpoint.name, + style: FaiTheme.mono( + size: 12, + weight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(width: FaiSpace.sm), + if (endpoint.workflowCount > 0) + FaiPill( + label: '${endpoint.workflowCount} workflows', + tone: FaiPillTone.success, + ) + else if (endpoint.errorKind.isNotEmpty) + FaiPill( + label: endpoint.errorKind, + tone: FaiPillTone.danger, + ), + ], + ), + Text( + endpoint.baseUrl, + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (endpoint.message.isNotEmpty) + Text( + endpoint.message, + style: theme.textTheme.bodySmall?.copyWith( + color: endpoint.errorKind.isEmpty + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.error, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 14), + tooltip: 'Remove endpoint', + visualDensity: VisualDensity.compact, + onPressed: onRemove, + ), + ], + ), + ); + } +} + +class _AddN8nEndpointDialog extends StatefulWidget { + const _AddN8nEndpointDialog(); + + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const _AddN8nEndpointDialog(), + ); + } + + @override + State<_AddN8nEndpointDialog> createState() => _AddN8nEndpointDialogState(); +} + +class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { + final _name = TextEditingController(); + final _baseUrl = TextEditingController(); + final _apiKey = TextEditingController(); + final _desc = TextEditingController(); + + @override + void dispose() { + _name.dispose(); + _baseUrl.dispose(); + _apiKey.dispose(); + _desc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Add n8n endpoint'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active workflows surface in the Store as ' + '`n8n..` capabilities.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name (no dots)', + hintText: 'ops', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _baseUrl, + decoration: const InputDecoration( + labelText: 'Base URL (without /api/v1)', + hintText: 'https://n8n.example.com', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _apiKey, + decoration: const InputDecoration( + labelText: 'API key env var (optional)', + hintText: 'N8N_OPS_KEY', + prefixText: '\$', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _desc, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return; + Navigator.pop( + context, + N8nEndpointDraft( + name: _name.text.trim(), + baseUrl: _baseUrl.text.trim(), + apiKeyEnv: _apiKey.text.trim(), + description: _desc.text.trim(), + ), + ); + }, + child: const Text('Add + discover'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index ff63fde..7dfb0c2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.12.1" + version: "0.13.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ccf4eab..ab6e60d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.22.0 +version: 0.23.0 environment: sdk: ^3.11.0-200.1.beta