From d0cfa5df05fe565b8e9a8175dfd338bef15f3b86 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 13 Jun 2026 14:16:07 +0200 Subject: [PATCH] feat: federation panel + Dart SDK federation methods (0.70.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new 'Föderation' destination (primary side) lists connected satellites — name, region, version, wire version, advertised capabilities — and adds them in one step: 'Add satellite' issues a single-use bootstrap token bundled with the primary CA as a ready-to-paste satellite config (the bundled CA makes the first connect tamper-proof). Localized EN + DE, in-app help doc. Uses the new HubService.listSatellites / issueSatelliteToken wrapping the SDK's federation methods. Signed-off-by: flemming-it --- CHANGELOG.md | 14 ++ assets/docs/federation.md | 43 ++++ assets/docs/federation_de.md | 48 +++++ lib/data/hub.dart | 78 +++++++ lib/l10n/app_de.arb | 20 +- lib/l10n/app_en.arb | 35 ++- lib/l10n/app_localizations.dart | 108 ++++++++++ lib/l10n/app_localizations_de.dart | 63 ++++++ lib/l10n/app_localizations_en.dart | 63 ++++++ lib/main.dart | 11 +- lib/pages/federation.dart | 334 +++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 12 files changed, 815 insertions(+), 4 deletions(-) create mode 100644 assets/docs/federation.md create mode 100644 assets/docs/federation_de.md create mode 100644 lib/pages/federation.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c130b..617635b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to `fai_studio` recorded here. Pubspec version + `kStudioVersion` in `lib/main.dart` stay in lockstep. +## 0.70.0 — 2026-06-13 + +### Added + +- **Federation panel.** A new "Föderation" destination shows the + satellites connected to this hub (name, region, version, wire + version, advertised capabilities) and adds them in one step: press + **Add satellite**, name it, and the hub issues a single-use + bootstrap token bundled with its CA as a ready-to-paste satellite + config — the bundled CA makes the satellite's first connect + tamper-proof. Localized (EN + DE) with an in-app help doc. Backed + by new `HubClient.listSatellites` / `issueBootstrapToken` in the + Dart SDK. + ## 0.68.0 — 2026-06-09 ### Added diff --git a/assets/docs/federation.md b/assets/docs/federation.md new file mode 100644 index 0000000..475cdf6 --- /dev/null +++ b/assets/docs/federation.md @@ -0,0 +1,43 @@ +# Federation + +Federation lets one hub (the **primary**) run capabilities on +another hub (a **satellite**) — for example to reach a GPU box or to +keep data inside a specific location. A flow step opts in with an +`on:` selector; everything else stays exactly like a local step. + +This panel is the **primary** side: it shows the satellites currently +connected and lets you enrol new ones. + +## Add a satellite + +1. Press **Add satellite** and give it a name (e.g. `satellite-a`). +2. The hub issues a single-use **bootstrap token** and bundles its + **CA certificate**. Copy the shown config into the satellite's + `config.yaml` (one paste) and export the token as + `FAI_BOOTSTRAP_TOKEN`. +3. Start the satellite. It dials the primary, the CA pin makes the + first connect tamper-proof, and the primary signs it a short-lived + certificate. It then appears in this list. + +## How it stays secure + +- The satellite **dials outbound** — no inbound firewall change. +- Every connection is **mTLS**. The first connect is verified against + the bundled CA; later connects authenticate with the satellite's + own certificate (no token needed). +- The satellite resolves and runs the capability in **its own + sandbox** — only inputs cross the wire, never code. +- Each cross-hub call is recorded on both sides (`dispatched_to`), so + the audit trail and replay determinism survive federation. + +## Using a satellite in a flow + +Add an `on:` selector to a step: + +```yaml +- id: extract + use: text.extract@^1 + on: region=berlin # or pin=satellite-a, auto:healthiest, auto:lowest_rtt + with: + input: $inputs.doc +``` diff --git a/assets/docs/federation_de.md b/assets/docs/federation_de.md new file mode 100644 index 0000000..b3f9628 --- /dev/null +++ b/assets/docs/federation_de.md @@ -0,0 +1,48 @@ +# Föderation + +Föderation lässt einen Hub (den **Primary**) Capabilities auf einem +anderen Hub (einem **Satelliten**) ausführen — etwa um eine GPU-Maschine +zu erreichen oder Daten an einem bestimmten Ort zu halten. Ein +Flow-Schritt entscheidet sich per `on:`-Selektor dafür; alles andere +bleibt wie bei einem lokalen Schritt. + +Dieses Panel ist die **Primary**-Seite: Es zeigt die aktuell +verbundenen Satelliten und richtet neue ein. + +## Satellit hinzufügen + +1. **Satellit hinzufügen** drücken und einen Namen vergeben + (z.B. `satellite-a`). +2. Der Hub stellt ein einmaliges **Bootstrap-Token** aus und legt sein + **CA-Zertifikat** bei. Kopiere die gezeigte Konfiguration in die + `config.yaml` des Satelliten (ein Einfügen) und exportiere das + Token als `FAI_BOOTSTRAP_TOKEN`. +3. Starte den Satelliten. Er wählt sich beim Primary ein, der CA-Pin + macht den ersten Connect manipulationssicher, und der Primary + signiert ihm ein kurzlebiges Zertifikat. Danach erscheint er in + dieser Liste. + +## Warum es sicher bleibt + +- Der Satellit **wählt sich nach außen ein** — keine eingehende + Firewall-Änderung nötig. +- Jede Verbindung ist **mTLS**. Der erste Connect wird gegen die + beigelegte CA verifiziert; spätere Connects authentifizieren sich + mit dem eigenen Zertifikat des Satelliten (kein Token nötig). +- Der Satellit löst auf und führt die Capability in **seiner eigenen + Sandbox** aus — nur Eingaben gehen über die Leitung, niemals Code. +- Jeder hub-übergreifende Aufruf wird auf beiden Seiten protokolliert + (`dispatched_to`), sodass Audit-Trail und Replay-Determinismus die + Föderation überstehen. + +## Satellit in einem Flow nutzen + +Ergänze einen `on:`-Selektor an einem Schritt: + +```yaml +- id: extract + use: text.extract@^1 + on: region=berlin # oder pin=satellite-a, auto:healthiest, auto:lowest_rtt + with: + input: $inputs.doc +``` diff --git a/lib/data/hub.dart b/lib/data/hub.dart index e2b1f22..0de2e2d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -859,6 +859,39 @@ class HubService { Future reject(String id, String reviewer, String reason) => _client.reject(approvalId: id, reviewer: reviewer, reason: reason); + /// Federation satellites currently connected to this hub + /// (primary side). Empty when none are connected. + Future> listSatellites() async { + final entries = await _client.listSatellites(); + return entries + .map( + (e) => Satellite( + hubId: e.hubId, + displayName: e.displayName, + hubVersion: e.hubVersion, + wireVersion: e.wireVersion, + region: e.region, + capabilities: e.capabilities.toList(), + ), + ) + .toList(); + } + + /// Issue a single-use federation bootstrap token enrolling + /// `name`. The result carries the primary CA so the operator can + /// bootstrap the satellite in one step (secure first connect). + Future issueSatelliteToken( + String name, { + String region = '', + }) async { + final r = await _client.issueBootstrapToken(name, region: region); + return SatelliteEnrollment( + token: r.token, + expiresAt: r.expiresAt, + primaryCaPem: r.primaryCaPem, + ); + } + /// Composite "doctor" snapshot. One round-trip per piece, run /// in parallel so the page populates fast. Future doctor() async { @@ -1208,6 +1241,51 @@ class PendingApproval { /// Full approval record including decided-side fields. Used by /// the History tab so operators can review what they (or the +/// A federation satellite currently connected to this hub. +class Satellite { + final String hubId; + final String displayName; + final String hubVersion; + final int wireVersion; + final String region; + final List capabilities; + + const Satellite({ + required this.hubId, + required this.displayName, + required this.hubVersion, + required this.wireVersion, + required this.region, + required this.capabilities, + }); +} + +/// The artifact a satellite operator needs to enrol: the single-use +/// bootstrap token plus the primary's CA cert (for a secure first +/// connect). Both are pasted into the satellite's config. +class SatelliteEnrollment { + final String token; + final String expiresAt; + final String primaryCaPem; + + const SatelliteEnrollment({ + required this.token, + required this.expiresAt, + required this.primaryCaPem, + }); + + /// A ready-to-paste `federation:` config block for the satellite. + String toConfigYaml(String name) { + final ca = primaryCaPem.trim().isEmpty + ? '' + : '\n primary_ca: |\n${primaryCaPem.trimRight().split('\n').map((l) => ' $l').join('\n')}'; + return 'federation:\n' + ' upstream: https://:\n' + ' hub_name: $name\n' + ' bootstrap_token_env: FAI_BOOTSTRAP_TOKEN$ca'; + } +} + /// system) decided. class ApprovalRecord { final String id; diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 68625fb..5b7dda8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1594,5 +1594,23 @@ "endpoint": {"type": "String"} } }, - "hubUnreachableOpenSettings": "Einstellungen öffnen" + "hubUnreachableOpenSettings": "Einstellungen öffnen", + "navFederation": "Föderation", + "federationTitle": "Föderation", + "federationReloadTooltip": "Satelliten neu laden", + "federationAddSatellite": "Satellit hinzufügen", + "federationEmptyTitle": "Keine Satelliten verbunden", + "federationEmptyHint": "Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.", + "federationPillConnected": "verbunden", + "federationRegionNone": "keine Region", + "federationCapabilities": "{count} angebotene Capabilities", + "federationAddDialogTitle": "Enrollment-Token ausstellen", + "federationNameLabel": "Satelliten-Name", + "federationIssueButton": "Ausstellen", + "federationIssueFailed": "Token konnte nicht ausgestellt werden: {error}", + "federationEnrollmentTitle": "{name} einrichten", + "federationTokenLabel": "Bootstrap-Token (einmalig)", + "federationConfigLabel": "Satelliten-Konfiguration (in den Satelliten einfügen)", + "federationCopied": "In die Zwischenablage kopiert", + "federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index af7293d..0be3e46 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1597,5 +1597,38 @@ "endpoint": {"type": "String"} } }, - "hubUnreachableOpenSettings": "Open Settings" + "hubUnreachableOpenSettings": "Open Settings", + "navFederation": "Federation", + "federationTitle": "Federation", + "federationReloadTooltip": "Reload satellites", + "federationAddSatellite": "Add satellite", + "federationEmptyTitle": "No satellites connected", + "federationEmptyHint": "Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.", + "federationPillConnected": "connected", + "federationRegionNone": "no region", + "federationCapabilities": "{count} advertised capabilities", + "@federationCapabilities": { + "placeholders": { + "count": {"type": "int"} + } + }, + "federationAddDialogTitle": "Issue an enrollment token", + "federationNameLabel": "Satellite name", + "federationIssueButton": "Issue", + "federationIssueFailed": "Could not issue token: {error}", + "@federationIssueFailed": { + "placeholders": { + "error": {"type": "String"} + } + }, + "federationEnrollmentTitle": "Enroll {name}", + "@federationEnrollmentTitle": { + "placeholders": { + "name": {"type": "String"} + } + }, + "federationTokenLabel": "Bootstrap token (single use)", + "federationConfigLabel": "Satellite config (paste into the satellite)", + "federationCopied": "Copied to clipboard", + "federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite's first connect tamper-proof." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 257ff77..748b286 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4470,6 +4470,114 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Open Settings'** String get hubUnreachableOpenSettings; + + /// No description provided for @navFederation. + /// + /// In en, this message translates to: + /// **'Federation'** + String get navFederation; + + /// No description provided for @federationTitle. + /// + /// In en, this message translates to: + /// **'Federation'** + String get federationTitle; + + /// No description provided for @federationReloadTooltip. + /// + /// In en, this message translates to: + /// **'Reload satellites'** + String get federationReloadTooltip; + + /// No description provided for @federationAddSatellite. + /// + /// In en, this message translates to: + /// **'Add satellite'** + String get federationAddSatellite; + + /// No description provided for @federationEmptyTitle. + /// + /// In en, this message translates to: + /// **'No satellites connected'** + String get federationEmptyTitle; + + /// No description provided for @federationEmptyHint. + /// + /// In en, this message translates to: + /// **'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'** + String get federationEmptyHint; + + /// No description provided for @federationPillConnected. + /// + /// In en, this message translates to: + /// **'connected'** + String get federationPillConnected; + + /// No description provided for @federationRegionNone. + /// + /// In en, this message translates to: + /// **'no region'** + String get federationRegionNone; + + /// No description provided for @federationCapabilities. + /// + /// In en, this message translates to: + /// **'{count} advertised capabilities'** + String federationCapabilities(int count); + + /// No description provided for @federationAddDialogTitle. + /// + /// In en, this message translates to: + /// **'Issue an enrollment token'** + String get federationAddDialogTitle; + + /// No description provided for @federationNameLabel. + /// + /// In en, this message translates to: + /// **'Satellite name'** + String get federationNameLabel; + + /// No description provided for @federationIssueButton. + /// + /// In en, this message translates to: + /// **'Issue'** + String get federationIssueButton; + + /// No description provided for @federationIssueFailed. + /// + /// In en, this message translates to: + /// **'Could not issue token: {error}'** + String federationIssueFailed(String error); + + /// No description provided for @federationEnrollmentTitle. + /// + /// In en, this message translates to: + /// **'Enroll {name}'** + String federationEnrollmentTitle(String name); + + /// No description provided for @federationTokenLabel. + /// + /// In en, this message translates to: + /// **'Bootstrap token (single use)'** + String get federationTokenLabel; + + /// No description provided for @federationConfigLabel. + /// + /// In en, this message translates to: + /// **'Satellite config (paste into the satellite)'** + String get federationConfigLabel; + + /// No description provided for @federationCopied. + /// + /// In en, this message translates to: + /// **'Copied to clipboard'** + String get federationCopied; + + /// No description provided for @federationEnrollmentHint. + /// + /// In en, this message translates to: + /// **'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'** + String get federationEnrollmentHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cef189b..7a4d453 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2632,4 +2632,67 @@ class AppLocalizationsDe extends AppLocalizations { @override String get hubUnreachableOpenSettings => 'Einstellungen öffnen'; + + @override + String get navFederation => 'Föderation'; + + @override + String get federationTitle => 'Föderation'; + + @override + String get federationReloadTooltip => 'Satelliten neu laden'; + + @override + String get federationAddSatellite => 'Satellit hinzufügen'; + + @override + String get federationEmptyTitle => 'Keine Satelliten verbunden'; + + @override + String get federationEmptyHint => + 'Verbinde einen weiteren Hub als Satellit, um Capabilities dort auszuführen. Stelle dazu ein Enrollment-Token aus.'; + + @override + String get federationPillConnected => 'verbunden'; + + @override + String get federationRegionNone => 'keine Region'; + + @override + String federationCapabilities(int count) { + return '$count angebotene Capabilities'; + } + + @override + String get federationAddDialogTitle => 'Enrollment-Token ausstellen'; + + @override + String get federationNameLabel => 'Satelliten-Name'; + + @override + String get federationIssueButton => 'Ausstellen'; + + @override + String federationIssueFailed(String error) { + return 'Token konnte nicht ausgestellt werden: $error'; + } + + @override + String federationEnrollmentTitle(String name) { + return '$name einrichten'; + } + + @override + String get federationTokenLabel => 'Bootstrap-Token (einmalig)'; + + @override + String get federationConfigLabel => + 'Satelliten-Konfiguration (in den Satelliten einfügen)'; + + @override + String get federationCopied => 'In die Zwischenablage kopiert'; + + @override + String get federationEnrollmentHint => + 'Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA macht den ersten Connect des Satelliten manipulationssicher.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2ff42b5..f105d4b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2635,4 +2635,67 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hubUnreachableOpenSettings => 'Open Settings'; + + @override + String get navFederation => 'Federation'; + + @override + String get federationTitle => 'Federation'; + + @override + String get federationReloadTooltip => 'Reload satellites'; + + @override + String get federationAddSatellite => 'Add satellite'; + + @override + String get federationEmptyTitle => 'No satellites connected'; + + @override + String get federationEmptyHint => + 'Connect another hub as a satellite to run capabilities on it. Issue an enrollment token to get started.'; + + @override + String get federationPillConnected => 'connected'; + + @override + String get federationRegionNone => 'no region'; + + @override + String federationCapabilities(int count) { + return '$count advertised capabilities'; + } + + @override + String get federationAddDialogTitle => 'Issue an enrollment token'; + + @override + String get federationNameLabel => 'Satellite name'; + + @override + String get federationIssueButton => 'Issue'; + + @override + String federationIssueFailed(String error) { + return 'Could not issue token: $error'; + } + + @override + String federationEnrollmentTitle(String name) { + return 'Enroll $name'; + } + + @override + String get federationTokenLabel => 'Bootstrap token (single use)'; + + @override + String get federationConfigLabel => + 'Satellite config (paste into the satellite)'; + + @override + String get federationCopied => 'Copied to clipboard'; + + @override + String get federationEnrollmentHint => + 'Hand the token to the satellite operator over a secure channel. The bundled CA makes the satellite\'s first connect tamper-proof.'; } diff --git a/lib/main.dart b/lib/main.dart index 0062c06..0d10ff7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; +import 'pages/federation.dart'; import 'pages/flows.dart'; import 'pages/store.dart'; import 'pages/welcome.dart'; @@ -27,7 +28,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.69.0'; +const String kStudioVersion = '0.70.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -348,6 +349,12 @@ class StudioShellState extends State { selectedIcon: Icons.inbox, page: ApprovalsPage(), ), + _NavPage( + id: 'federation', + icon: Icons.hub_outlined, + selectedIcon: Icons.hub, + page: FederationPage(), + ), ]; /// Pending-approval count surfaced as a sidebar badge so the @@ -1397,6 +1404,8 @@ class _NavPage { return l.navAudit; case 'approvals': return l.navApprovals; + case 'federation': + return l.navFederation; default: return id; } diff --git a/lib/pages/federation.dart b/lib/pages/federation.dart new file mode 100644 index 0000000..3f744aa --- /dev/null +++ b/lib/pages/federation.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import '../widgets/widgets.dart'; +import 'welcome.dart' show showFaiDoc; + +/// Federation panel: the satellites connected to this hub (primary +/// side) and a one-step "add satellite" flow that issues a bootstrap +/// token + the primary CA as a ready-to-paste config. +class FederationPage extends StatefulWidget { + const FederationPage({super.key}); + + @override + State createState() => _FederationPageState(); +} + +class _FederationPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _refresh(); + } + + void _refresh() { + setState(() { + _future = HubService.instance.listSatellites(); + }); + } + + Future _addSatellite() async { + final name = await _promptName(context); + if (name == null || name.isEmpty) return; + if (!mounted) return; + final l = AppLocalizations.of(context)!; + try { + final enrollment = await HubService.instance.issueSatelliteToken(name); + if (!mounted) return; + await showDialog( + context: context, + builder: (_) => _EnrollmentDialog(name: name, enrollment: enrollment), + ); + _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.federationIssueFailed(e.toString()))), + ); + } + } + + Future _promptName(BuildContext context) async { + final controller = TextEditingController(); + final l = AppLocalizations.of(context)!; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.federationAddDialogTitle), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: l.federationNameLabel, + hintText: 'satellite-a', + border: const OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, null), + child: Text(l.buttonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: Text(l.federationIssueButton), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: Text(l.federationTitle), + actions: [ + IconButton( + icon: const Icon(Icons.help_outline, size: 18), + tooltip: l.helpTooltip, + onPressed: () => showFaiDoc(context, 'federation'), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + tooltip: l.federationReloadTooltip, + onPressed: _refresh, + ), + const SizedBox(width: FaiSpace.sm), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _addSatellite, + icon: const Icon(Icons.add_link), + label: Text(l.federationAddSatellite), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + action: FilledButton.tonal( + onPressed: _refresh, + child: Text(l.buttonRetry), + ), + ); + } + final sats = snapshot.data ?? []; + if (sats.isEmpty) { + return FaiEmptyState( + icon: Icons.hub_outlined, + title: l.federationEmptyTitle, + hint: l.federationEmptyHint, + action: FilledButton.icon( + onPressed: _addSatellite, + icon: const Icon(Icons.add_link, size: 16), + label: Text(l.federationAddSatellite), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(FaiSpace.xl), + itemCount: sats.length, + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), + itemBuilder: (context, i) => _SatelliteCard(satellite: sats[i]), + ); + }, + ), + ); + } +} + +class _SatelliteCard extends StatelessWidget { + final Satellite satellite; + const _SatelliteCard({required this.satellite}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return FaiCard( + accentTop: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FaiPill( + label: l.federationPillConnected, + tone: FaiPillTone.success, + icon: Icons.link, + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + satellite.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + FaiPill( + label: satellite.region.isEmpty + ? l.federationRegionNone + : satellite.region, + tone: FaiPillTone.neutral, + icon: Icons.public, + ), + ], + ), + const SizedBox(height: FaiSpace.md), + Wrap( + spacing: FaiSpace.md, + runSpacing: FaiSpace.xs, + children: [ + _meta(theme, 'v${satellite.hubVersion}'), + _meta(theme, 'wire ${satellite.wireVersion}'), + _meta(theme, satellite.hubId), + ], + ), + if (satellite.capabilities.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.md), + Text( + l.federationCapabilities(satellite.capabilities.length), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: satellite.capabilities + .map( + (c) => FaiPill( + label: c, + tone: FaiPillTone.accent, + icon: Icons.extension_outlined, + ), + ) + .toList(), + ), + ], + ], + ), + ); + } + + Widget _meta(ThemeData theme, String text) => Text( + text, + style: FaiTheme.mono(size: 10, color: theme.colorScheme.onSurfaceVariant), + ); +} + +/// Shows the freshly issued token + a ready-to-paste satellite config +/// (with the primary CA inlined) the operator copies over. +class _EnrollmentDialog extends StatelessWidget { + final String name; + final SatelliteEnrollment enrollment; + const _EnrollmentDialog({required this.name, required this.enrollment}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final config = enrollment.toConfigYaml(name); + return AlertDialog( + title: Text(l.federationEnrollmentTitle(name)), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 520), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.federationTokenLabel, style: theme.textTheme.labelSmall), + const SizedBox(height: 4), + _CopyableBlock(text: enrollment.token, copiedMsg: l.federationCopied), + const SizedBox(height: FaiSpace.md), + Text(l.federationConfigLabel, style: theme.textTheme.labelSmall), + const SizedBox(height: 4), + _CopyableBlock(text: config, copiedMsg: l.federationCopied), + const SizedBox(height: FaiSpace.md), + Text( + l.federationEnrollmentHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.buttonClose), + ), + ], + ); + } +} + +class _CopyableBlock extends StatelessWidget { + final String text; + final String copiedMsg; + const _CopyableBlock({required this.text, required this.copiedMsg}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Stack( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + FaiSpace.md, + FaiSpace.md, + 40, + FaiSpace.md, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + text, + style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), + ), + ), + Positioned( + top: 2, + right: 2, + child: IconButton( + icon: const Icon(Icons.copy, size: 16), + tooltip: copiedMsg, + onPressed: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(copiedMsg))); + }, + ), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 543ccc6..4a794ad 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.69.0 +version: 0.70.0 environment: sdk: ^3.11.0-200.1.beta