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:
parent
9ef32df31e
commit
d2a6ed32e2
5 changed files with 435 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue