feat(studio): n8n-endpoints editor in Settings (v0.23.0)

Mirror of the MCP-clients panel: Add / Remove / Refresh
with last-discovery health (workflow count or error_kind),
sister Add-dialog with name + base_url + optional
api_key_env + notes. HubService gains the four passthroughs
(`listN8nEndpoints`, `refreshN8nEndpoints`,
`addN8nEndpoint`, `removeN8nEndpoint`) and a
`N8nEndpointInfo` data class.

Settings dialog now hosts: hub endpoint, channels,
system AI, MCP clients, n8n endpoints — all behind the
existing 520×640 SingleChildScrollView so the layout
doesn't crowd.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 01:00:17 +02:00
parent 9ef32df31e
commit d2a6ed32e2
5 changed files with 435 additions and 3 deletions

View file

@ -388,6 +388,53 @@ class HubService {
.toList();
}
/// Snapshot of every configured n8n endpoint.
Future<List<N8nEndpointInfo>> listN8nEndpoints() async {
final r = await _client.listN8nEndpoints();
return _mapN8nEndpoints(r);
}
Future<List<N8nEndpointInfo>> refreshN8nEndpoints() async {
final r = await _client.refreshN8nEndpoints();
return _mapN8nEndpoints(r);
}
Future<List<N8nEndpointInfo>> 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<List<N8nEndpointInfo>> removeN8nEndpoint(String name) async {
final r = await _client.removeN8nEndpoint(name);
return _mapN8nEndpoints(r);
}
List<N8nEndpointInfo> _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<ChannelStatusSnapshot> 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 {

View file

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -38,6 +38,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
ChannelStatusSnapshot? _channels;
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints;
@override
void initState() {
@ -49,6 +50,62 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_loadChannels();
_loadAiStatus();
_loadMcpClients();
_loadN8nEndpoints();
}
Future<void> _loadN8nEndpoints() async {
try {
final list = await HubService.instance.listN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (_) {/* fail-quiet */}
}
Future<void> _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<void> _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<void> _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<void> _loadMcpClients() async {
@ -391,6 +448,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
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<N8nEndpointInfo> endpoints;
final ValueChanged<N8nEndpointDraft> onAdd;
final ValueChanged<String> 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.<endpoint>.<slug>` 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<N8nEndpointDraft?> show(BuildContext context) {
return showDialog<N8nEndpointDraft>(
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.<name>.<workflow_slug>` 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'),
),
],
);
}
}

View file

@ -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:

View file

@ -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