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();
|
.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.
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||||
final r = await _client.channelStatus();
|
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.
|
/// One configured MCP server plus the last-discovery report.
|
||||||
/// Surfaced by Studio's MCP-clients editor in Settings.
|
/// Surfaced by Studio's MCP-clients editor in Settings.
|
||||||
class McpClientInfo {
|
class McpClientInfo {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// 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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
ChannelStatusSnapshot? _channels;
|
ChannelStatusSnapshot? _channels;
|
||||||
SystemAiStatus? _aiStatus;
|
SystemAiStatus? _aiStatus;
|
||||||
List<McpClientInfo>? _mcpClients;
|
List<McpClientInfo>? _mcpClients;
|
||||||
|
List<N8nEndpointInfo>? _n8nEndpoints;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -49,6 +50,62 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
_loadChannels();
|
_loadChannels();
|
||||||
_loadAiStatus();
|
_loadAiStatus();
|
||||||
_loadMcpClients();
|
_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 {
|
Future<void> _loadMcpClients() async {
|
||||||
|
|
@ -391,6 +448,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
onRefresh: _refreshMcpClients,
|
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"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.12.1"
|
version: "0.13.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.22.0
|
version: 0.23.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue