feat(doctor): update banner via HubAdmin.CheckUpdate (v0.9.0)

Doctor page now opens with a slim banner above the summary strip
in two cases: an actually newer version on the channel ("Update
available — v0.x.y", success-tone "new" pill, release-notes URL
when present) or the release host being unreachable
("Release host unreachable", neutral "offline" pill, with the
network reason). When local matches latest the banner stays
hidden — quiet UI is the right default.

Hooks into the existing parallel-fetch in `HubService.doctor` so
the new RPC adds zero latency to the page load.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-06 15:53:52 +02:00
parent 753db4fc60
commit 18db6ff164
5 changed files with 113 additions and 3 deletions

View file

@ -210,12 +210,16 @@ class HubService {
(_) => VerifyEventChainResponse(),
),
_client.listServices().catchError((_) => <DeclaredService>[]),
_client.checkUpdate().catchError(
(_) => CheckUpdateResponse(),
),
]);
final caps = results[0] as List<CapabilityEntry>;
final approvals = results[1] as List<PendingApprovalEntry>;
final chain = results[2] as VerifyEventChainResponse;
final services = results[3] as List<DeclaredService>;
final update = results[4] as CheckUpdateResponse;
// Module count = distinct module_name across capabilities.
final moduleNames = <String>{for (final c in caps) c.moduleName};
@ -237,10 +241,40 @@ class HubService {
),
)
.toList(),
update: UpdateStatus(
channel: update.channel,
localVersion: update.localVersion,
latestVersion: update.latestVersion,
updateAvailable: update.updateAvailable,
manifestReachable: update.manifestReachable,
releaseNotesUrl:
update.releaseNotesUrl.isEmpty ? null : update.releaseNotesUrl,
reason: update.reason.isEmpty ? null : update.reason,
),
);
}
}
class UpdateStatus {
final String channel;
final String localVersion;
final String latestVersion;
final bool updateAvailable;
final bool manifestReachable;
final String? releaseNotesUrl;
final String? reason;
const UpdateStatus({
required this.channel,
required this.localVersion,
required this.latestVersion,
required this.updateAvailable,
required this.manifestReachable,
this.releaseNotesUrl,
this.reason,
});
}
class DoctorSnapshot {
final int moduleCount;
final int capabilityCount;
@ -249,6 +283,7 @@ class DoctorSnapshot {
final int eventChainVerified;
final String? eventChainTamperedAt;
final List<ServiceEntry> services;
final UpdateStatus update;
const DoctorSnapshot({
required this.moduleCount,
@ -258,6 +293,7 @@ class DoctorSnapshot {
required this.eventChainVerified,
required this.eventChainTamperedAt,
required this.services,
required this.update,
});
bool get chainHealthy => eventChainTamperedAt == null;

View file

@ -22,7 +22,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.8.0';
const String kStudioVersion = '0.9.0';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -59,9 +59,14 @@ class _DoctorPageState extends State<DoctorPage> {
);
}
final s = snapshot.data!;
final showUpdate = s.update.updateAvailable ||
(!s.update.manifestReachable &&
s.update.localVersion.isNotEmpty);
return ListView(
padding: const EdgeInsets.all(FaiSpace.xl),
children: [
if (showUpdate) _UpdateBanner(status: s.update),
if (showUpdate) const SizedBox(height: FaiSpace.lg),
_SummaryStrip(snapshot: s),
const SizedBox(height: FaiSpace.xl),
_Section(
@ -431,3 +436,72 @@ class _ServicesPanel extends StatelessWidget {
);
}
}
class _UpdateBanner extends StatelessWidget {
final UpdateStatus status;
const _UpdateBanner({required this.status});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final available = status.updateAvailable;
final iconData = available
? Icons.system_update_alt_outlined
: Icons.cloud_off_outlined;
final iconColor = available
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
final title = available
? 'Update available — ${status.latestVersion}'
: 'Release host unreachable';
final body = available
? 'Channel ${status.channel} now offers ${status.latestVersion}. '
'Apply with: fai update apply --channel ${status.channel}'
: (status.reason ?? 'Could not contact the release feed for ${status.channel}.');
return FaiCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(iconData, size: 20, color: iconColor),
const SizedBox(width: FaiSpace.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
body,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (available && status.releaseNotesUrl != null) ...[
const SizedBox(height: 4),
Text(
'notes: ${status.releaseNotesUrl}',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.primary,
),
),
],
],
),
),
FaiPill(
label: available ? 'new' : 'offline',
tone: available ? FaiPillTone.success : FaiPillTone.neutral,
),
],
),
);
}
}

View file

@ -79,7 +79,7 @@ packages:
path: "../fai_dart_sdk"
relative: true
source: path
version: "0.1.0"
version: "0.2.0"
fake_async:
dependency: transitive
description:

View file

@ -1,7 +1,7 @@
name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none'
version: 0.8.0
version: 0.9.0
environment:
sdk: ^3.11.0-200.1.beta