feat(studio): module uninstall + Doctor daemon control (v0.17.0)

UI parity for Windows operators who never touch a shell:

- Module sheet gains an Uninstall button at the bottom.
  Two-step confirmation, then calls UninstallModule + closes
  the sheet + refreshes the Modules page.

- Doctor page picks up two new sections:
  * Daemon files — log / config / audit DB / modules /
    flows / pid, each with a one-click "Open" button that
    shells out to the OS handler (open / xdg-open /
    explorer).
  * Daemon control — Restart / Stop / Status buttons that
    spawn `fai daemon …`. Captured stdout/stderr renders
    inline so the operator sees what happened.

- Update banner gains an "Apply update" button that spawns
  `fai update apply --channel <c>`. The previous version
  showed the command as text — Windows users had no way to
  execute it.

- Event log panel gains a "Verify now" button that re-runs
  the chain check (via the existing doctor refresh).

New SystemActions helper resolves the `fai` binary via
$FAI_BIN → PATH → `~/.fai/bin/fai` (or the Windows
equivalent), so the buttons work whether the operator
restarted their shell after installing or not.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 17:46:46 +02:00
parent d9aabfd00a
commit 73e394b39f
8 changed files with 624 additions and 49 deletions

View file

@ -303,6 +303,29 @@ class HubService {
return (purged: r.purged.toInt(), channel: r.channel);
}
/// Filesystem paths the daemon owns. Used by Doctor to wire
/// "Open log file" / "Open config file" buttons.
Future<DaemonPathsSnapshot> daemonPaths() async {
final r = await _client.daemonPaths();
return DaemonPathsSnapshot(
logPath: r.logPath,
dbPath: r.dbPath,
modulesDir: r.modulesDir,
flowsDir: r.flowsDir,
configPath: r.configPath,
pidPath: r.pidPath,
);
}
/// Remove an installed module by name. Returns the version
/// that was removed so the caller can confirm.
Future<({String name, String version})> uninstallModule(
String name,
) async {
final r = await _client.uninstallModule(name);
return (name: r.name, version: r.version);
}
/// Active channel + per-channel daemon status snapshot.
Future<ChannelStatusSnapshot> channelStatus() async {
final r = await _client.channelStatus();
@ -521,6 +544,7 @@ class HubService {
_client.checkUpdate().catchError(
(_) => CheckUpdateResponse(),
),
_client.daemonPaths().catchError((_) => DaemonPathsResponse()),
]);
final caps = results[0] as List<CapabilityEntry>;
@ -528,6 +552,7 @@ class HubService {
final chain = results[2] as VerifyEventChainResponse;
final services = results[3] as List<DeclaredService>;
final update = results[4] as CheckUpdateResponse;
final paths = results[5] as DaemonPathsResponse;
// Module count = distinct module_name across capabilities.
final moduleNames = <String>{for (final c in caps) c.moduleName};
@ -559,6 +584,14 @@ class HubService {
update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl,
reason: update.reason.isEmpty ? null : update.reason,
),
paths: DaemonPathsSnapshot(
logPath: paths.logPath,
dbPath: paths.dbPath,
modulesDir: paths.modulesDir,
flowsDir: paths.flowsDir,
configPath: paths.configPath,
pidPath: paths.pidPath,
),
);
}
}
@ -592,6 +625,9 @@ class DoctorSnapshot {
final String? eventChainTamperedAt;
final List<ServiceEntry> services;
final UpdateStatus update;
/// Filesystem paths the daemon owns. Empty fields when the
/// hub couldn't resolve them (older daemon, no $HOME).
final DaemonPathsSnapshot paths;
const DoctorSnapshot({
required this.moduleCount,
@ -602,6 +638,7 @@ class DoctorSnapshot {
required this.eventChainTamperedAt,
required this.services,
required this.update,
required this.paths,
});
bool get chainHealthy => eventChainTamperedAt == null;
@ -868,6 +905,28 @@ class ChannelStatusSnapshot {
});
}
/// Filesystem paths the daemon owns. Studio's Doctor page
/// surfaces these as "Open" buttons so Windows operators who
/// never touch a shell can still inspect everything via the OS
/// file manager / default editor.
class DaemonPathsSnapshot {
final String logPath;
final String dbPath;
final String modulesDir;
final String flowsDir;
final String configPath;
final String pidPath;
const DaemonPathsSnapshot({
required this.logPath,
required this.dbPath,
required this.modulesDir,
required this.flowsDir,
required this.configPath,
required this.pidPath,
});
}
/// Detected host hardware see
/// docs/architecture/system-ai.md "Hardware tiers".
class HardwareSnapshot {