diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a16e9..ee59dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ lockstep. ## Unreleased +### Added (0.74.0) + +- **Doctor findings deep-link to their page.** Summary tiles and + panel rows jump to where the finding is actioned: modules → store, + "approvals waiting for review" → approvals inbox, audit chain → + audit log (chevron affordance, tooltip + semantics button). The + host-services empty state gains a "view the configuration" button, + and the update banner's release-notes URL is a real link now. + +### Fixed (0.74.0) + +- **Runs page no longer contradicts the sidebar.** Load failures are + classified instead of all reading "hub not reachable": an older hub + without the ListInvocations RPC gets "this view needs a newer hub + version" with a doctor link, the detached-off refusal shows the + regular feature-off empty state, and unknown errors surface a + copyable detail box. "Not reachable" is reserved for genuine + connection failures. +- **Audit page timer leak.** The live-stream reconnect timer is held + and cancelled on dispose. +- **a11y suite flake killed at the root.** The pending-timer failures + came from the gRPC channel's 5-minute idle timer being armed when + the last stream closes; the suite now closes the channel and pumps + past the idle timeout (6 consecutive full runs green). + ### Added - **Full-history audit export.** The audit page's actions menu gains diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 790d978..af79ecf 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.73.0'; +const String kStudioVersion = '0.74.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index d12efce..e415485 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -217,6 +217,16 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) { return null; } +/// Duck-typed `GrpcError.code` reader — public so pages that +/// classify errors themselves (e.g. the runs monitor separating +/// "hub down" from "hub too old") share one accessor instead of +/// re-implementing the duck-typing. +int? grpcCodeOf(Object error) => _intField(error, 'code'); + +/// Duck-typed `GrpcError.message` reader — companion to +/// [grpcCodeOf]. +String? grpcMessageOf(Object error) => _stringField(error, 'message'); + /// Try to read an `int` field by name off an arbitrary object. /// Returns `null` when the field doesn't exist or has another /// runtime type. Used to duck-type `GrpcError.code` without diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 23112c3..d0dc1cd 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,6 +5,7 @@ // Methods return UI-friendly types so pages stay free of // protobuf imports. +import 'dart:async' show unawaited; import 'dart:io'; import 'dart:typed_data'; @@ -196,6 +197,24 @@ class HubService { } } + /// Test hook: close the gRPC channel and replace the client with + /// a fresh, not-yet-dialled one (same endpoint + token). Suites + /// that pump the real app against no hub end with this so the + /// channel's internal reconnect/idle timers cannot outlive the + /// test body — a real socket failure landing late otherwise arms + /// a new backoff timer and trips the framework's pending-timer + /// invariant (the a11y suite's long-standing flake). + @visibleForTesting + void debugResetChannel() { + final old = _client; + _client = HubClient(endpoint: old.endpoint, authToken: _lastAuthToken); + // Deliberately not awaited: shutdown() wedges on a socket that + // is mid-connect, but the channel marks itself shut down and + // cancels its timers synchronously at the start — which is all + // this hook needs. + unawaited(old.close().catchError((_) {})); + } + /// Reload the token from disk and reconnect using the current /// endpoint. Called by Settings after the operator pastes or /// clears a token. diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32357d0..09f7b7d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1184,6 +1184,11 @@ } }, "doctorSummaryDeclared": "deklariert", + "doctorLinkStore": "Store öffnen", + "doctorLinkApprovals": "Freigaben öffnen", + "doctorLinkAudit": "Protokoll öffnen", + "doctorLinkConfig": "Konfiguration ansehen", + "doctorLinkReleaseNotes": "Versionshinweise im Browser öffnen", "doctorModulesPanelSummary": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}", "@doctorModulesPanelSummary": { "placeholders": { @@ -1773,6 +1778,10 @@ "runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.", "runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.", "runsEmptyGuideButton": "Anleitung öffnen", + "runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version", + "runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.", + "runsHubTooOldButton": "Diagnose öffnen", + "runsLoadFailedTitle": "Läufe konnten nicht geladen werden", "runsCancelButton": "Abbrechen", "runsCancelSignalled": "Abbruch für {flow} ausgelöst.", "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c08eea7..8280776 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1208,6 +1208,11 @@ } }, "doctorSummaryDeclared": "declared", + "doctorLinkStore": "Open the store", + "doctorLinkApprovals": "Open approvals", + "doctorLinkAudit": "Open the audit log", + "doctorLinkConfig": "View the configuration", + "doctorLinkReleaseNotes": "Open the release notes in the browser", "doctorModulesPanelSummary": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}", "@doctorModulesPanelSummary": { "placeholders": { @@ -1812,6 +1817,10 @@ "runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.", "runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.", "runsEmptyGuideButton": "Open the guide", + "runsHubTooOldTitle": "This view needs a newer hub version", + "runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.", + "runsHubTooOldButton": "Open Doctor", + "runsLoadFailedTitle": "Runs could not be loaded", "runsCancelButton": "Cancel", "runsCancelSignalled": "Cancel signalled for {flow}.", "@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index db946a6..d2a8e09 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3662,6 +3662,36 @@ abstract class AppLocalizations { /// **'declared'** String get doctorSummaryDeclared; + /// No description provided for @doctorLinkStore. + /// + /// In en, this message translates to: + /// **'Open the store'** + String get doctorLinkStore; + + /// No description provided for @doctorLinkApprovals. + /// + /// In en, this message translates to: + /// **'Open approvals'** + String get doctorLinkApprovals; + + /// No description provided for @doctorLinkAudit. + /// + /// In en, this message translates to: + /// **'Open the audit log'** + String get doctorLinkAudit; + + /// No description provided for @doctorLinkConfig. + /// + /// In en, this message translates to: + /// **'View the configuration'** + String get doctorLinkConfig; + + /// No description provided for @doctorLinkReleaseNotes. + /// + /// In en, this message translates to: + /// **'Open the release notes in the browser'** + String get doctorLinkReleaseNotes; + /// No description provided for @doctorModulesPanelSummary. /// /// In en, this message translates to: @@ -5401,6 +5431,30 @@ abstract class AppLocalizations { /// **'Open the guide'** String get runsEmptyGuideButton; + /// No description provided for @runsHubTooOldTitle. + /// + /// In en, this message translates to: + /// **'This view needs a newer hub version'** + String get runsHubTooOldTitle; + + /// No description provided for @runsHubTooOldHint. + /// + /// In en, this message translates to: + /// **'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.'** + String get runsHubTooOldHint; + + /// No description provided for @runsHubTooOldButton. + /// + /// In en, this message translates to: + /// **'Open Doctor'** + String get runsHubTooOldButton; + + /// No description provided for @runsLoadFailedTitle. + /// + /// In en, this message translates to: + /// **'Runs could not be loaded'** + String get runsLoadFailedTitle; + /// No description provided for @runsCancelButton. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f714a53..4a56518 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorSummaryDeclared => 'deklariert'; + @override + String get doctorLinkStore => 'Store öffnen'; + + @override + String get doctorLinkApprovals => 'Freigaben öffnen'; + + @override + String get doctorLinkAudit => 'Protokoll öffnen'; + + @override + String get doctorLinkConfig => 'Konfiguration ansehen'; + + @override + String get doctorLinkReleaseNotes => 'Versionshinweise im Browser öffnen'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( @@ -3198,6 +3213,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runsEmptyGuideButton => 'Anleitung öffnen'; + @override + String get runsHubTooOldTitle => + 'Diese Ansicht braucht eine neuere Hub-Version'; + + @override + String get runsHubTooOldHint => + 'Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.'; + + @override + String get runsHubTooOldButton => 'Diagnose öffnen'; + + @override + String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden'; + @override String get runsCancelButton => 'Abbrechen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2e6ee2c..f20b7e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorSummaryDeclared => 'declared'; + @override + String get doctorLinkStore => 'Open the store'; + + @override + String get doctorLinkApprovals => 'Open approvals'; + + @override + String get doctorLinkAudit => 'Open the audit log'; + + @override + String get doctorLinkConfig => 'View the configuration'; + + @override + String get doctorLinkReleaseNotes => 'Open the release notes in the browser'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( @@ -3195,6 +3210,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runsEmptyGuideButton => 'Open the guide'; + @override + String get runsHubTooOldTitle => 'This view needs a newer hub version'; + + @override + String get runsHubTooOldHint => + 'The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.'; + + @override + String get runsHubTooOldButton => 'Open Doctor'; + + @override + String get runsLoadFailedTitle => 'Runs could not be loaded'; + @override String get runsCancelButton => 'Cancel'; diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index ce454a3..d9e7645 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -81,6 +81,7 @@ class _AuditPageState extends State { /// filtering, and the hash-chain ordering exactly as before. StreamSubscription? _eventSub; Timer? _nudgeDebounce; + Timer? _reconnect; @override void initState() { @@ -97,6 +98,7 @@ class _AuditPageState extends State { Workspace.instance.removeListener(_onWorkspaceChanged); _poller?.cancel(); _nudgeDebounce?.cancel(); + _reconnect?.cancel(); _eventSub?.cancel(); super.dispose(); } @@ -122,7 +124,12 @@ class _AuditPageState extends State { onDone: () { _eventSub = null; if (mounted) { - Timer(const Duration(seconds: 3), () { + // Held in a field so dispose() can cancel it — an + // anonymous timer here outlives the page when the + // stream closes right before navigation (the a11y + // suite caught this as a pending-timer flake). + _reconnect?.cancel(); + _reconnect = Timer(const Duration(seconds: 3), () { if (mounted && _eventSub == null) _subscribeLive(); }); } diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 2079e36..32c6ae8 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -7,6 +7,7 @@ import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -75,23 +76,49 @@ class _DoctorPageState extends State { final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); + // Findings that have a dedicated page link straight to it — + // "approvals waiting for review" must be one tap away from + // the approvals inbox, not a dead-end statement. + final shell = StudioShellState.of(context); + final openStore = shell == null + ? null + : () => shell.navigateTo('store'); + final openApprovals = shell == null + ? null + : () => shell.navigateTo('approvals'); + final openAudit = shell == null + ? null + : () => shell.navigateTo('audit'); return ListView( padding: const EdgeInsets.all(ChainSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: ChainSpace.lg), - _SummaryStrip(snapshot: s), + _SummaryStrip( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + onOpenAudit: openAudit, + ), const SizedBox(height: ChainSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, - child: _EventLogPanel(snapshot: s, onRefresh: _refresh), + child: _EventLogPanel( + snapshot: s, + onRefresh: _refresh, + onOpenAudit: openAudit, + ), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of( context, )!.doctorModulesApprovalsSection, - child: _ModulesPanel(snapshot: s), + child: DoctorModulesPanel( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + ), ), const SizedBox(height: ChainSpace.lg), _Section( @@ -150,22 +177,32 @@ class _Section extends StatelessWidget { class _SummaryStrip extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; + final VoidCallback? onOpenAudit; - const _SummaryStrip({required this.snapshot}); + const _SummaryStrip({ + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ - _StatTile( + DoctorStatTile( label: l.doctorSummaryModules, value: snapshot.moduleCount.toString(), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), icon: Icons.extension_outlined, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, @@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.pendingApprovals > 0 ? ChainPillTone.warning : ChainPillTone.neutral, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( @@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.chainHealthy ? ChainPillTone.success : ChainPillTone.danger, + onTap: onOpenAudit, + linkLabel: l.doctorLinkAudit, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, @@ -199,19 +240,32 @@ class _SummaryStrip extends StatelessWidget { } } -class _StatTile extends StatelessWidget { +/// One summary tile of the doctor page. Public + callback-driven so +/// the deep-link widget test can pump it directly: when [onTap] is +/// set, the whole tile becomes a button (chevron affordance, +/// tooltip + semantics from [linkLabel]) that jumps to the page +/// where the finding can be acted on. +class DoctorStatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final ChainPillTone tone; + final VoidCallback? onTap; - const _StatTile({ + /// Human-readable tap target ("Open approvals"). Required when + /// [onTap] is set; doubles as tooltip and semantics label. + final String? linkLabel; + + const DoctorStatTile({ + super.key, required this.label, required this.value, required this.subtitle, required this.icon, this.tone = ChainPillTone.neutral, + this.onTap, + this.linkLabel, }); @override @@ -223,49 +277,72 @@ class _StatTile extends StatelessWidget { ChainPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; - return Expanded( - child: ChainCard( - accentLeft: accentColor, - padding: const EdgeInsets.all(ChainSpace.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: ChainSpace.xs), - Flexible( - child: Text( - label.toUpperCase(), - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - fontSize: 10, - ), + Widget card = ChainCard( + accentLeft: accentColor, + padding: const EdgeInsets.all(ChainSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: ChainSpace.xs), + Flexible( + child: Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontSize: 10, ), ), + ), + if (onTap != null) ...[ + const Spacer(), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), ], + ], + ), + const SizedBox(height: ChainSpace.sm), + Text( + value, + style: theme.textTheme.displaySmall?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, ), - const SizedBox(height: ChainSpace.sm), - Text( - value, - style: theme.textTheme.displaySmall?.copyWith( - color: accentColor, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), ); + if (onTap != null) { + // ChainCard is a plain Container, so the InkWell needs its own + // transparent Material to paint the hover/ripple feedback. + card = Tooltip( + message: linkLabel ?? '', + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.md), + child: Semantics(button: true, label: linkLabel, child: card), + ), + ), + ); + } + return Expanded(child: card); } } @@ -277,13 +354,60 @@ class _EventLogPanel extends StatelessWidget { /// after import / restore without restarting the daemon. final VoidCallback onRefresh; - const _EventLogPanel({required this.snapshot, required this.onRefresh}); + /// Jumps to the audit page — the finding's detail view (per-event + /// list, forensic exports) lives there. + final VoidCallback? onOpenAudit; + + const _EventLogPanel({ + required this.snapshot, + required this.onRefresh, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; final l = AppLocalizations.of(context)!; + Widget headline = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + healthy ? l.doctorChainIntact : l.doctorChainTampered, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + healthy + ? l.doctorChainIntactDetail(snapshot.eventChainTotal) + : l.doctorChainTamperedDetail( + snapshot.eventChainVerified, + snapshot.eventChainTotal, + snapshot.eventChainTamperedAt ?? '', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + if (onOpenAudit != null) { + headline = Tooltip( + message: l.doctorLinkAudit, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onOpenAudit, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: l.doctorLinkAudit, + child: headline), + ), + ), + ); + } return ChainCard( child: Row( children: [ @@ -293,32 +417,7 @@ class _EventLogPanel extends StatelessWidget { color: healthy ? ChainColors.success : theme.colorScheme.error, ), const SizedBox(width: ChainSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - healthy ? l.doctorChainIntact : l.doctorChainTampered, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - healthy - ? l.doctorChainIntactDetail(snapshot.eventChainTotal) - : l.doctorChainTamperedDetail( - snapshot.eventChainVerified, - snapshot.eventChainTotal, - snapshot.eventChainTamperedAt ?? '', - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + Expanded(child: headline), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), @@ -834,10 +933,43 @@ String _sourceKindLabel(String kind) { } } -class _ModulesPanel extends StatelessWidget { +/// The modules + approvals findings card. Public + callback-driven +/// so the deep-link widget test can pump it without a hub: each row +/// that has a dedicated page is a tap target ("approvals waiting +/// for review" jumps to the approvals inbox, the modules line to +/// the store). +class DoctorModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; - const _ModulesPanel({required this.snapshot}); + const DoctorModulesPanel({ + super.key, + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + }); + + Widget _linkRow( + BuildContext context, { + required Widget child, + required VoidCallback? onTap, + required String linkLabel, + }) { + if (onTap == null) return child; + return Tooltip( + message: linkLabel, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: linkLabel, child: child), + ), + ), + ); + } @override Widget build(BuildContext context) { @@ -847,69 +979,95 @@ class _ModulesPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - Icons.extension_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.doctorModulesPanelSummary( - snapshot.moduleCount, - snapshot.capabilityCount, - ), - style: theme.textTheme.bodyMedium, - ), - if (snapshot.capabilitiesBySource.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _sourceBreakdown(snapshot.capabilitiesBySource), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], + _linkRow( + context, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, + child: Row( + children: [ + Icon( + Icons.extension_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ), - ChainPill( - label: snapshot.moduleCount > 0 - ? l.doctorPillLoaded - : l.doctorPillEmpty, - tone: snapshot.moduleCount > 0 - ? ChainPillTone.success - : ChainPillTone.neutral, - ), - ], + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.doctorModulesPanelSummary( + snapshot.moduleCount, + snapshot.capabilityCount, + ), + style: theme.textTheme.bodyMedium, + ), + if (snapshot.capabilitiesBySource.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _sourceBreakdown(snapshot.capabilitiesBySource), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ChainPill( + label: snapshot.moduleCount > 0 + ? l.doctorPillLoaded + : l.doctorPillEmpty, + tone: snapshot.moduleCount > 0 + ? ChainPillTone.success + : ChainPillTone.neutral, + ), + if (onOpenStore != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), const Divider(height: ChainSpace.xl), - Row( - children: [ - Icon( - Icons.inbox_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Text( - snapshot.pendingApprovals == 0 - ? l.doctorApprovalsNone - : l.doctorApprovalsCount(snapshot.pendingApprovals), - style: theme.textTheme.bodyMedium, - ), - const Spacer(), - if (snapshot.pendingApprovals > 0) - ChainPill( - label: l.doctorApprovalsAttentionPill, - tone: ChainPillTone.warning, + _linkRow( + context, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, + child: Row( + children: [ + Icon( + Icons.inbox_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ], + const SizedBox(width: ChainSpace.sm), + Text( + snapshot.pendingApprovals == 0 + ? l.doctorApprovalsNone + : l.doctorApprovalsCount(snapshot.pendingApprovals), + style: theme.textTheme.bodyMedium, + ), + const Spacer(), + if (snapshot.pendingApprovals > 0) + ChainPill( + label: l.doctorApprovalsAttentionPill, + tone: ChainPillTone.warning, + ), + if (onOpenApprovals != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), ], ), @@ -960,6 +1118,21 @@ class _ServicesPanel extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), + // The hint names config.yaml — put the file one tap away + // instead of making the operator hunt for it. + if (snapshot.paths.configPath.isNotEmpty) + OutlinedButton.icon( + onPressed: () => showFaiConfigViewer( + context, + path: snapshot.paths.configPath, + title: l.doctorPathConfig, + ), + icon: const Icon(Icons.settings_outlined, size: 14), + label: Text(l.doctorLinkConfig), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), ], ), ); @@ -1085,11 +1258,32 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), - Text( - l.doctorReleaseNotes(status.releaseNotesUrl!), - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.primary, + // A URL the operator cannot click is a finding + // without a link — open it in the browser. + Tooltip( + message: l.doctorLinkReleaseNotes, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => SystemActions.openInOs( + status.releaseNotesUrl!, + ), + child: Semantics( + button: true, + label: l.doctorLinkReleaseNotes, + child: Text( + l.doctorReleaseNotes(status.releaseNotesUrl!), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ).copyWith( + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + ), + ), ), ), ], diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 6f458c0..64fdf57 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/workspace.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; import 'welcome.dart' show showFaiDoc; @@ -22,10 +24,138 @@ class RunsPage extends StatefulWidget { State createState() => _RunsPageState(); } +/// Why the runs monitor could not load its list. The page used to +/// fold every failure into "hub not reachable", which contradicted +/// the sidebar's green "connected" dot whenever the hub answered +/// with an RPC-level error (usertest finding: a 0.21 hub without +/// the ListInvocations RPC). Top-level so the unit test drives the +/// classification directly. +enum RunsLoadIssue { + /// The hub itself cannot be reached (socket-level failure or + /// gRPC UNAVAILABLE / DEADLINE_EXCEEDED). + unreachable, + + /// The hub answered, but its version predates the + /// ListInvocations RPC (gRPC UNIMPLEMENTED) — connected, just + /// too old for this view. + unsupported, + + /// The hub answered and refused because detached invocations + /// are switched off (gRPC FAILED_PRECONDITION from the detached + /// gate) — show the regular feature-off empty state, not a + /// connection error. + featureDisabled, + + /// Anything else — show the friendly error with copyable detail. + other, +} + +/// Classify a [HubService.listDetachedRuns] failure into the view +/// state the page should render. +RunsLoadIssue classifyRunsLoadError(Object error) { + switch (grpcCodeOf(error)) { + case 12: // UNIMPLEMENTED — hub predates the RPC + return RunsLoadIssue.unsupported; + case 9: // FAILED_PRECONDITION — the hub's detached gate + final msg = (grpcMessageOf(error) ?? '').toLowerCase(); + return msg.contains('detached') + ? RunsLoadIssue.featureDisabled + : RunsLoadIssue.other; + case 4: // DEADLINE_EXCEEDED + case 14: // UNAVAILABLE + return RunsLoadIssue.unreachable; + } + // Non-gRPC failures: only clear socket-level shapes count as + // "unreachable"; everything else keeps its real story. + final s = error.toString().toLowerCase(); + if (s.contains('socketexception') || + s.contains('connection refused') || + s.contains('connection terminated') || + s.contains('failed to connect')) { + return RunsLoadIssue.unreachable; + } + return RunsLoadIssue.other; +} + +/// The error rendition of the runs monitor — public + callback-driven +/// so the widget test can pump each variant without a live hub. +class RunsLoadErrorView extends StatelessWidget { + final Object error; + final RunsLoadIssue issue; + + /// Opens the runs guide (the feature-off empty state's CTA). + final VoidCallback onOpenGuide; + + /// Opens the doctor page (the "hub too old" state's CTA, where + /// the update banner lives). Null hides the button. + final VoidCallback? onOpenDoctor; + + const RunsLoadErrorView({ + super.key, + required this.error, + required this.issue, + required this.onOpenGuide, + this.onOpenDoctor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + switch (issue) { + case RunsLoadIssue.featureDisabled: + // Same story as the regular feature-off empty state: the + // hub is fine, the operator just has not enabled the + // feature — never claim "not reachable" here. + return ChainEmptyState( + icon: Icons.rocket_launch_outlined, + title: l.runsEmptyTitle, + hint: l.runsEmptyHint, + action: OutlinedButton.icon( + icon: const Icon(Icons.menu_book_outlined, size: 16), + label: Text(l.runsEmptyGuideButton), + onPressed: onOpenGuide, + ), + ); + case RunsLoadIssue.unsupported: + return ChainEmptyState( + icon: Icons.system_update_alt_outlined, + title: l.runsHubTooOldTitle, + hint: l.runsHubTooOldHint, + action: onOpenDoctor == null + ? null + : OutlinedButton.icon( + icon: const Icon(Icons.health_and_safety_outlined, size: 16), + label: Text(l.runsHubTooOldButton), + onPressed: onOpenDoctor, + ), + ); + case RunsLoadIssue.unreachable: + return ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, + ); + case RunsLoadIssue.other: + return ChainEmptyState( + icon: Icons.error_outline, + iconColor: theme.colorScheme.error, + title: l.runsLoadFailedTitle, + action: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: ChainErrorBox(error: error, isError: true), + ), + ); + } + } +} + class _RunsPageState extends State { List _runs = const []; bool _detachedEnabled = false; - String? _error; + Object? _error; + RunsLoadIssue _issue = RunsLoadIssue.other; bool _loaded = false; Timer? _poll; final Set _cancelling = {}; @@ -63,7 +193,8 @@ class _RunsPageState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); + _error = e; + _issue = classifyRunsLoadError(e); _loaded = true; }); } @@ -118,11 +249,12 @@ class _RunsPageState extends State { body: !_loaded ? const Center(child: CircularProgressIndicator()) : _error != null && _runs.isEmpty - ? ChainEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: l.hubUnreachable, - hint: l.hubUnreachableHint, + ? RunsLoadErrorView( + error: _error!, + issue: _issue, + onOpenGuide: () => showFaiDoc(context, 'runs'), + onOpenDoctor: () => + StudioShellState.of(context)?.navigateTo('doctor'), ) : _runs.isEmpty ? ChainEmptyState( diff --git a/pubspec.yaml b/pubspec.yaml index 362f75e..1c8dcbc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.73.0 +version: 0.74.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/a11y_test.dart b/test/a11y_test.dart index 3ef853a..981e52b 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -88,6 +88,19 @@ void main() { // !timersPending after the test body. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); + // The suite's long-standing pending-timer flake, finally + // caught with a creation stack: when the last gRPC stream + // closes, Http2ClientConnection._handleActiveStateChanged + // arms the channel's 5-minute idleTimeout timer — even on a + // shut-down connection — so a 1-minute drain never covered + // it. Close the channel in real-async space (lets in-flight + // socket callbacks land), then pump PAST the idle timeout so + // the timer fires inside the test body. + await tester.runAsync(() async { + HubService.instance.debugResetChannel(); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(const Duration(minutes: 6)); expect( violations, isEmpty, diff --git a/test/doctor_deeplinks_test.dart b/test/doctor_deeplinks_test.dart new file mode 100644 index 0000000..1b44597 --- /dev/null +++ b/test/doctor_deeplinks_test.dart @@ -0,0 +1,122 @@ +// Doctor deep links — findings with a dedicated page must be one +// tap away from it (usertest follow-up: "approvals waiting for +// review" was a dead-end statement). The tiles and panel rows are +// public callback-driven widgets so these tests pump each variant +// without a live hub. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/doctor.dart'; + +const _snapshot = DoctorSnapshot( + moduleCount: 2, + capabilityCount: 3, + pendingApprovals: 1, + eventChainTotal: 5, + eventChainVerified: 5, + eventChainTamperedAt: null, + services: [], + update: UpdateStatus( + channel: 'stable', + localVersion: '0.22.0', + latestVersion: '0.22.0', + updateAvailable: false, + manifestReachable: true, + ), + paths: DaemonPathsSnapshot( + logPath: '', + dbPath: '', + modulesDir: '', + flowsDir: '', + configPath: '', + pidPath: '', + ), +); + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +void main() { + testWidgets('a linked stat tile is a labelled button that fires its target', ( + tester, + ) async { + var opened = false; + await tester.pumpWidget( + _host( + Row( + children: [ + DoctorStatTile( + label: 'Freigaben', + value: '1', + subtitle: 'warten auf Prüfung', + icon: Icons.inbox_outlined, + onTap: () => opened = true, + linkLabel: 'Freigaben öffnen', + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Affordance: chevron + semantics button with the link label. + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('Freigaben öffnen')), + findsOneWidget, + ); + await tester.tap(find.text('1')); + expect(opened, isTrue); + }); + + testWidgets('an unlinked stat tile stays a plain card', (tester) async { + await tester.pumpWidget( + _host( + const Row( + children: [ + DoctorStatTile( + label: 'Dienste', + value: '0', + subtitle: 'deklariert', + icon: Icons.dns_outlined, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.chevron_right), findsNothing); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets( + 'the approvals row links to approvals, the modules row to the store', + (tester) async { + var storeOpened = false; + var approvalsOpened = false; + await tester.pumpWidget( + _host( + DoctorModulesPanel( + snapshot: _snapshot, + onOpenStore: () => storeOpened = true, + onOpenApprovals: () => approvalsOpened = true, + ), + ), + ); + await tester.pumpAndSettle(); + // The pending-approvals finding must be tappable ("1 Freigabe + // wartet auf Prüfung" -> approvals inbox). + await tester.tap(find.textContaining('wartet auf Prüfung')); + expect(approvalsOpened, isTrue); + expect(storeOpened, isFalse); + await tester.tap(find.textContaining('2 Module')); + expect(storeOpened, isTrue); + }, + ); +} diff --git a/test/runs_error_classification_test.dart b/test/runs_error_classification_test.dart new file mode 100644 index 0000000..0249aab --- /dev/null +++ b/test/runs_error_classification_test.dart @@ -0,0 +1,172 @@ +// Runs-monitor error classification — the page used to render +// every load failure as "hub not reachable", contradicting the +// sidebar's green "connected" dot whenever the hub answered but +// the RPC failed (usertest finding: a 0.21 hub without the +// ListInvocations RPC answered UNIMPLEMENTED and the page claimed +// the hub was down). These tests pin the classification and the +// widget rendition of each state. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/runs.dart'; + +/// Duck-typed stand-in for `GrpcError` — the classifier reads +/// `.code` and `.message` off whatever object arrives, exactly +/// like the friendly-error mapper does. +class _FakeGrpcError { + final int code; + final String? message; + const _FakeGrpcError(this.code, [this.message]); + + @override + String toString() => 'gRPC Error (code: $code, message: $message)'; +} + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +void main() { + group('classifyRunsLoadError', () { + test('UNIMPLEMENTED means the hub is too old, not unreachable', () { + expect( + classifyRunsLoadError( + const _FakeGrpcError(12, 'grpc.Hub/ListInvocations unimplemented'), + ), + RunsLoadIssue.unsupported, + ); + }); + + test('FAILED_PRECONDITION from the detached gate is feature-off', () { + expect( + classifyRunsLoadError( + const _FakeGrpcError( + 9, + 'detached invocations are not enabled — set detached.enabled: ' + 'true in the operator config', + ), + ), + RunsLoadIssue.featureDisabled, + ); + }); + + test('other FAILED_PRECONDITION errors keep their own story', () { + expect( + classifyRunsLoadError(const _FakeGrpcError(9, 'store busy')), + RunsLoadIssue.other, + ); + }); + + test('UNAVAILABLE and DEADLINE_EXCEEDED are unreachable', () { + expect( + classifyRunsLoadError(const _FakeGrpcError(14, 'connection refused')), + RunsLoadIssue.unreachable, + ); + expect( + classifyRunsLoadError(const _FakeGrpcError(4, 'deadline exceeded')), + RunsLoadIssue.unreachable, + ); + }); + + test('socket-level failures without a gRPC code are unreachable', () { + expect( + classifyRunsLoadError( + Exception('SocketException: Connection refused (port 50051)'), + ), + RunsLoadIssue.unreachable, + ); + }); + + test('arbitrary errors fall through to other', () { + expect( + classifyRunsLoadError(const FormatException('bad payload')), + RunsLoadIssue.other, + ); + }); + }); + + group('RunsLoadErrorView', () { + testWidgets('feature-off renders the plain-language empty state, ' + 'never "not reachable"', (tester) async { + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError( + 9, + 'detached invocations are not enabled', + ), + issue: RunsLoadIssue.featureDisabled, + onOpenGuide: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Keine Läufe im Hintergrund'), findsOneWidget); + expect(find.text('Anleitung öffnen'), findsOneWidget); + expect(find.text('Hub nicht erreichbar'), findsNothing); + }); + + testWidgets('too-old hub says so and links to the doctor page', ( + tester, + ) async { + var doctorOpened = false; + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError(12, 'unimplemented'), + issue: RunsLoadIssue.unsupported, + onOpenGuide: () {}, + onOpenDoctor: () => doctorOpened = true, + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Diese Ansicht braucht eine neuere Hub-Version'), + findsOneWidget, + ); + expect(find.text('Hub nicht erreichbar'), findsNothing); + await tester.tap(find.text('Diagnose öffnen')); + expect(doctorOpened, isTrue); + }); + + testWidgets('a genuinely unreachable hub still says unreachable', ( + tester, + ) async { + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError(14, 'connection refused'), + issue: RunsLoadIssue.unreachable, + onOpenGuide: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Hub nicht erreichbar'), findsOneWidget); + }); + + testWidgets('other errors surface a copyable detail box', (tester) async { + await tester.pumpWidget( + _host( + RunsLoadErrorView( + error: const _FakeGrpcError(13, 'internal boom'), + issue: RunsLoadIssue.other, + onOpenGuide: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Läufe konnten nicht geladen werden'), findsOneWidget); + expect(find.text('Hub nicht erreichbar'), findsNothing); + // The copy affordance is the hard rule: the detail box must + // be present so the operator can copy the real message. + expect(find.byIcon(Icons.content_copy), findsWidgets); + }); + }); +}