feat(studio): hub maintenance panel with reset action (v0.45.0)
Some checks are pending
Security / Security check (push) Waiting to run

UI parity with the platform CLI: `fai reset` (v0.10.93) is now
reachable from Settings. The panel shows the reset blurb plus
two checkboxes mirroring the CLI flags (Keep modules / Keep
audit log + saved flows), then a destructive-styled "Reset hub
state" button. Click goes through a confirm dialog before
SystemActions.faiReset is invoked.

After the CLI returns, Studio bounces its gRPC channel against
the same endpoint (the daemon restarts on the same channel +
port, so no Settings change needed) and reloads every panel:
channel status, system AI, MCP clients, n8n endpoints, registry
token. Output (success or failure) is shown in a copyable error
box so any unexpected stderr lands somewhere the operator can
paste from.

Sits at the bottom of the dialog under "HUB MAINTENANCE",
separated from the everyday config so a destructive button
doesn't crowd the day-to-day controls.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 18:52:19 +02:00
parent 9944a7ac8a
commit 3db7db7330
8 changed files with 358 additions and 1 deletions

View file

@ -125,6 +125,25 @@ class SystemActions {
return _runFai(['daemon', 'disable', '--channel', channel]);
}
/// Run `fai reset --yes`. Wipes operator state with the same
/// safety net as the CLI: stops every daemon (including off-
/// channel orphans), atomically backs `~/.fai/` up, recreates
/// it with `bin/`, `channels/`, `config.yaml`, `current-channel`,
/// and `registry-token` preserved. The daemon restarts on the
/// active channel before the call returns.
///
/// Studio's gRPC channel goes down for ~1s while the daemon
/// restarts caller should follow up with a reconnect.
static Future<({bool ok, String stdout, String stderr})> faiReset({
bool keepModules = false,
bool keepData = false,
}) async {
final args = <String>['reset', '--yes'];
if (keepModules) args.add('--keep-modules');
if (keepData) args.add('--keep-data');
return _runFai(args);
}
static Future<({bool ok, String stdout, String stderr})> _runFai(
List<String> args,
) async {

View file

@ -449,6 +449,17 @@
"@registryTokenSaveFailedToast": {
"placeholders": { "error": { "type": "String" } }
},
"maintenanceHeader": "HUB-WARTUNG",
"maintenanceResetBlurb": "Operator-Zustand zurücksetzen und mit einem sauberen Hub neu starten. Stoppt jeden laufenden Daemon, sichert ~/.fai/ atomar in ein Backup, legt es dann neu an — Binary, Channel-Pointer, MCP-/n8n-/System-AI-Konfiguration und Registry-Token bleiben erhalten. Wiederherstellen durch Zurückverschieben des Backup-Ordners.",
"maintenanceKeepModulesTitle": "Installierte Module behalten",
"maintenanceKeepModulesSubtitle": "Standardverhalten löscht ~/.fai/modules/, damit eine frische Installation den Modul-Bezugspfad end-to-end verifizieren kann.",
"maintenanceKeepDataTitle": "Protokoll + gespeicherte Flows behalten",
"maintenanceKeepDataSubtitle": "Standardverhalten löscht ~/.fai/data/, damit die Audit-Kette mit einer neuen Genesis startet und die mitgelieferten Sample-Flows beim ersten Daemon-Start neu importiert werden.",
"maintenanceResetButton": "Hub zurücksetzen",
"maintenanceResetInProgress": "Zurücksetzen…",
"maintenanceResetConfirmTitle": "Hub zurücksetzen?",
"maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.fai/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?",
"maintenanceResetConfirmButton": "Zurücksetzen",
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.",
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:",
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",

View file

@ -450,6 +450,17 @@
"@registryTokenSaveFailedToast": {
"placeholders": { "error": { "type": "String" } }
},
"maintenanceHeader": "HUB MAINTENANCE",
"maintenanceResetBlurb": "Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.",
"maintenanceKeepModulesTitle": "Keep installed modules",
"maintenanceKeepModulesSubtitle": "Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.",
"maintenanceKeepDataTitle": "Keep audit log + saved flows",
"maintenanceKeepDataSubtitle": "Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.",
"maintenanceResetButton": "Reset hub state",
"maintenanceResetInProgress": "Resetting…",
"maintenanceResetConfirmTitle": "Reset hub state?",
"maintenanceResetConfirmBody": "This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?",
"maintenanceResetConfirmButton": "Reset",
"welcomeChecklistAllSetTitle": "You're set up.",
"welcomeChecklistAllSetBody": "Three threads to pull on next:",
"welcomeChecklistNextAuditTitle": "Read the audit log",

View file

@ -2042,6 +2042,72 @@ abstract class AppLocalizations {
/// **'Could not save: {error}'**
String registryTokenSaveFailedToast(String error);
/// No description provided for @maintenanceHeader.
///
/// In en, this message translates to:
/// **'HUB MAINTENANCE'**
String get maintenanceHeader;
/// No description provided for @maintenanceResetBlurb.
///
/// In en, this message translates to:
/// **'Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.'**
String get maintenanceResetBlurb;
/// No description provided for @maintenanceKeepModulesTitle.
///
/// In en, this message translates to:
/// **'Keep installed modules'**
String get maintenanceKeepModulesTitle;
/// No description provided for @maintenanceKeepModulesSubtitle.
///
/// In en, this message translates to:
/// **'Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.'**
String get maintenanceKeepModulesSubtitle;
/// No description provided for @maintenanceKeepDataTitle.
///
/// In en, this message translates to:
/// **'Keep audit log + saved flows'**
String get maintenanceKeepDataTitle;
/// No description provided for @maintenanceKeepDataSubtitle.
///
/// In en, this message translates to:
/// **'Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.'**
String get maintenanceKeepDataSubtitle;
/// No description provided for @maintenanceResetButton.
///
/// In en, this message translates to:
/// **'Reset hub state'**
String get maintenanceResetButton;
/// No description provided for @maintenanceResetInProgress.
///
/// In en, this message translates to:
/// **'Resetting…'**
String get maintenanceResetInProgress;
/// No description provided for @maintenanceResetConfirmTitle.
///
/// In en, this message translates to:
/// **'Reset hub state?'**
String get maintenanceResetConfirmTitle;
/// No description provided for @maintenanceResetConfirmBody.
///
/// In en, this message translates to:
/// **'This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?'**
String get maintenanceResetConfirmBody;
/// No description provided for @maintenanceResetConfirmButton.
///
/// In en, this message translates to:
/// **'Reset'**
String get maintenanceResetConfirmButton;
/// No description provided for @welcomeChecklistAllSetTitle.
///
/// In en, this message translates to:

View file

@ -1159,6 +1159,44 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Speichern fehlgeschlagen: $error';
}
@override
String get maintenanceHeader => 'HUB-WARTUNG';
@override
String get maintenanceResetBlurb =>
'Operator-Zustand zurücksetzen und mit einem sauberen Hub neu starten. Stoppt jeden laufenden Daemon, sichert ~/.fai/ atomar in ein Backup, legt es dann neu an — Binary, Channel-Pointer, MCP-/n8n-/System-AI-Konfiguration und Registry-Token bleiben erhalten. Wiederherstellen durch Zurückverschieben des Backup-Ordners.';
@override
String get maintenanceKeepModulesTitle => 'Installierte Module behalten';
@override
String get maintenanceKeepModulesSubtitle =>
'Standardverhalten löscht ~/.fai/modules/, damit eine frische Installation den Modul-Bezugspfad end-to-end verifizieren kann.';
@override
String get maintenanceKeepDataTitle =>
'Protokoll + gespeicherte Flows behalten';
@override
String get maintenanceKeepDataSubtitle =>
'Standardverhalten löscht ~/.fai/data/, damit die Audit-Kette mit einer neuen Genesis startet und die mitgelieferten Sample-Flows beim ersten Daemon-Start neu importiert werden.';
@override
String get maintenanceResetButton => 'Hub zurücksetzen';
@override
String get maintenanceResetInProgress => 'Zurücksetzen…';
@override
String get maintenanceResetConfirmTitle => 'Hub zurücksetzen?';
@override
String get maintenanceResetConfirmBody =>
'Stoppt jeden Daemon, verschiebt ~/.fai/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?';
@override
String get maintenanceResetConfirmButton => 'Zurücksetzen';
@override
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.';

View file

@ -1174,6 +1174,43 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Could not save: $error';
}
@override
String get maintenanceHeader => 'HUB MAINTENANCE';
@override
String get maintenanceResetBlurb =>
'Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.';
@override
String get maintenanceKeepModulesTitle => 'Keep installed modules';
@override
String get maintenanceKeepModulesSubtitle =>
'Default behaviour wipes ~/.fai/modules/ so a fresh install can verify the module-fetch path end-to-end.';
@override
String get maintenanceKeepDataTitle => 'Keep audit log + saved flows';
@override
String get maintenanceKeepDataSubtitle =>
'Default behaviour wipes ~/.fai/data/ so the audit chain restarts from a fresh genesis and the bundled sample flows re-import on first daemon start.';
@override
String get maintenanceResetButton => 'Reset hub state';
@override
String get maintenanceResetInProgress => 'Resetting…';
@override
String get maintenanceResetConfirmTitle => 'Reset hub state?';
@override
String get maintenanceResetConfirmBody =>
'This stops every daemon, moves ~/.fai/ to a timestamped backup, and starts fresh. The backup is fully restorable. Continue?';
@override
String get maintenanceResetConfirmButton => 'Reset';
@override
String get welcomeChecklistAllSetTitle => 'You\'re set up.';

View file

@ -509,6 +509,24 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.lg),
_MaintenancePanel(
onResetDone: () async {
// Daemon just restarted under the same channel +
// port. Bouncing the gRPC connection picks up the
// fresh state without the operator having to
// close+reopen Settings.
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
),
],
),
),
@ -1437,6 +1455,163 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
/// metadata; nothing is auto-spawned. Trust model:
/// referencing public projects with their canonical install
/// command is the same legal / security shape as a package
/// Hub maintenance panel currently one action: reset operator
/// state via `fai reset --yes`. Lives at the bottom of Settings
/// so the everyday config controls aren't crowded by a destructive
/// button. The reset itself is gated by an interactive confirm
/// dialog before the CLI is invoked.
class _MaintenancePanel extends StatefulWidget {
/// Called after a successful reset so the parent Settings dialog
/// can bounce its gRPC channel and reload its panels.
final Future<void> Function() onResetDone;
const _MaintenancePanel({required this.onResetDone});
@override
State<_MaintenancePanel> createState() => _MaintenancePanelState();
}
class _MaintenancePanelState extends State<_MaintenancePanel> {
bool _keepModules = false;
bool _keepData = false;
bool _resetting = false;
String? _resultText;
bool _resultOk = false;
Future<void> _confirmAndReset() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.maintenanceResetConfirmTitle),
content: Text(l.maintenanceResetConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.buttonCancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.maintenanceResetConfirmButton),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_resetting = true;
_resultText = null;
});
final r = await SystemActions.faiReset(
keepModules: _keepModules,
keepData: _keepData,
);
if (!mounted) return;
setState(() {
_resetting = false;
_resultOk = r.ok;
_resultText = r.ok
? r.stdout.trim()
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
});
if (r.ok) await widget.onResetDone();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.maintenanceHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.maintenanceResetBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepModules,
onChanged: _resetting
? null
: (v) => setState(() => _keepModules = v ?? false),
title: Text(l.maintenanceKeepModulesTitle),
subtitle: Text(
l.maintenanceKeepModulesSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepData,
onChanged: _resetting
? null
: (v) => setState(() => _keepData = v ?? false),
title: Text(l.maintenanceKeepDataTitle),
subtitle: Text(
l.maintenanceKeepDataSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
FilledButton.icon(
icon: _resetting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.restart_alt, size: 16),
label: Text(
_resetting
? l.maintenanceResetInProgress
: l.maintenanceResetButton,
),
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
),
onPressed: _resetting ? null : _confirmAndReset,
),
],
),
if (_resultText != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(
text: _resultText!,
isError: !_resultOk,
maxHeight: 240,
),
],
],
);
}
}
/// Credentials panel for the operator's registry auth token.
/// The hub reads the same token via `~/.fai/registry-token`
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)