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:
parent
d9aabfd00a
commit
73e394b39f
8 changed files with 624 additions and 49 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
122
lib/data/system_actions.dart
Normal file
122
lib/data/system_actions.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// System-level actions Studio invokes by spawning the `fai`
|
||||
// binary or asking the OS to open a file. Used by Doctor's
|
||||
// daemon-control + open-paths affordances so Windows operators
|
||||
// who never touch a shell can restart the daemon, apply an
|
||||
// update, or open a log file from inside the GUI.
|
||||
//
|
||||
// Cross-platform notes:
|
||||
// - macOS uses `open` for files + URLs.
|
||||
// - Linux uses `xdg-open`.
|
||||
// - Windows uses `explorer` (file manager) and a quoted CLI
|
||||
// call for processes.
|
||||
//
|
||||
// The `fai` binary is resolved from PATH first; falls back to
|
||||
// `~/.fai/bin/fai` (Unix) or `%USERPROFILE%\.fai\bin\fai.exe`
|
||||
// (Windows). The PowerShell installer puts both in PATH but
|
||||
// some operators don't restart their shell after install — the
|
||||
// fallback covers that case.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
class SystemActions {
|
||||
SystemActions._();
|
||||
|
||||
/// Ask the OS to open [path] in the default handler. On macOS
|
||||
/// this opens text files in TextEdit, configs in the registered
|
||||
/// editor, etc. Returns true on a clean spawn (process exited
|
||||
/// non-zero is still surfaced as false).
|
||||
static Future<({bool ok, String stderr})> openInOs(String path) async {
|
||||
if (path.isEmpty) {
|
||||
return (ok: false, stderr: 'empty path');
|
||||
}
|
||||
final cmd = _openCommand();
|
||||
try {
|
||||
final r = await Process.run(cmd.executable, [...cmd.args, path]);
|
||||
if (r.exitCode == 0) return (ok: true, stderr: '');
|
||||
return (ok: false, stderr: r.stderr.toString().trim());
|
||||
} catch (e) {
|
||||
return (ok: false, stderr: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a `fai daemon ...` subcommand and surface the captured
|
||||
/// output. Used by Doctor's "Restart daemon" button.
|
||||
static Future<({bool ok, String stdout, String stderr})> faiDaemon(
|
||||
List<String> args,
|
||||
) async {
|
||||
return _runFai(['daemon', ...args]);
|
||||
}
|
||||
|
||||
/// Run `fai update apply --channel <c>`. Long-running on a slow
|
||||
/// network — caller should show a spinner.
|
||||
static Future<({bool ok, String stdout, String stderr})> faiUpdateApply(
|
||||
String channel,
|
||||
) async {
|
||||
return _runFai(['update', 'apply', '--channel', channel]);
|
||||
}
|
||||
|
||||
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
||||
List<String> args,
|
||||
) async {
|
||||
final exe = _faiExecutable();
|
||||
if (exe == null) {
|
||||
return (
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: 'Could not locate the `fai` binary. Install via the '
|
||||
'platform installer or set FAI_BIN to its full path.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
final r = await Process.run(exe, args);
|
||||
return (
|
||||
ok: r.exitCode == 0,
|
||||
stdout: r.stdout.toString(),
|
||||
stderr: r.stderr.toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
return (ok: false, stdout: '', stderr: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
static ({String executable, List<String> args}) _openCommand() {
|
||||
if (Platform.isMacOS) return (executable: 'open', args: const []);
|
||||
if (Platform.isWindows) return (executable: 'explorer', args: const []);
|
||||
return (executable: 'xdg-open', args: const []);
|
||||
}
|
||||
|
||||
/// Locate the `fai` binary. Order: $FAI_BIN, PATH, fallback to
|
||||
/// the canonical install location under the user's home dir.
|
||||
static String? _faiExecutable() {
|
||||
final fromEnv = Platform.environment['FAI_BIN'];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
final isWindows = Platform.isWindows;
|
||||
final fromPath = _whichFai(isWindows ? 'fai.exe' : 'fai');
|
||||
if (fromPath != null) return fromPath;
|
||||
|
||||
final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME'];
|
||||
if (home == null || home.isEmpty) return null;
|
||||
final fallback = isWindows
|
||||
? '$home\\.fai\\bin\\fai.exe'
|
||||
: '$home/.fai/bin/fai';
|
||||
return File(fallback).existsSync() ? fallback : null;
|
||||
}
|
||||
|
||||
/// Tiny PATH walker — we cannot rely on `which`/`where` being
|
||||
/// reachable via Process.run on every platform.
|
||||
static String? _whichFai(String name) {
|
||||
final pathEnv = Platform.environment['PATH'];
|
||||
if (pathEnv == null || pathEnv.isEmpty) return null;
|
||||
final sep = Platform.isWindows ? ';' : ':';
|
||||
final slash = Platform.isWindows ? '\\' : '/';
|
||||
for (final dir in pathEnv.split(sep)) {
|
||||
if (dir.isEmpty) continue;
|
||||
final candidate = '$dir$slash$name';
|
||||
if (File(candidate).existsSync()) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue