From 18db6ff16440cecd5a8e1a92b0b6e7850b37a42d Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 6 May 2026 15:53:52 +0200 Subject: [PATCH] feat(doctor): update banner via HubAdmin.CheckUpdate (v0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/hub.dart | 36 +++++++++++++++++++++ lib/main.dart | 2 +- lib/pages/doctor.dart | 74 +++++++++++++++++++++++++++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 2 +- 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1264a81..cb0afd5 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -210,12 +210,16 @@ class HubService { (_) => VerifyEventChainResponse(), ), _client.listServices().catchError((_) => []), + _client.checkUpdate().catchError( + (_) => CheckUpdateResponse(), + ), ]); final caps = results[0] as List; final approvals = results[1] as List; final chain = results[2] as VerifyEventChainResponse; final services = results[3] as List; + final update = results[4] as CheckUpdateResponse; // Module count = distinct module_name across capabilities. final moduleNames = {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 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; diff --git a/lib/main.dart b/lib/main.dart index 42cc9c8..ed54962 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index b749e3a..04eb1f8 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -59,9 +59,14 @@ class _DoctorPageState extends State { ); } 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, + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d840040..1ed17f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index c199c96..f37763c 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.8.0 +version: 0.9.0 environment: sdk: ^3.11.0-200.1.beta