diff --git a/CHANGELOG.md b/CHANGELOG.md index ee59dda..34a16e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,31 +6,6 @@ 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 af79ecf..790d978 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.74.0'; +const String kStudioVersion = '0.73.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 e415485..d12efce 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -217,16 +217,6 @@ 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 d0dc1cd..23112c3 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -5,7 +5,6 @@ // Methods return UI-friendly types so pages stay free of // protobuf imports. -import 'dart:async' show unawaited; import 'dart:io'; import 'dart:typed_data'; @@ -197,24 +196,6 @@ 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 09f7b7d..32357d0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1184,11 +1184,6 @@ } }, "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": { @@ -1778,10 +1773,6 @@ "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 8280776..c08eea7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1208,11 +1208,6 @@ } }, "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": { @@ -1817,10 +1812,6 @@ "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 d2a8e09..db946a6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3662,36 +3662,6 @@ 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: @@ -5431,30 +5401,6 @@ 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 4a56518..f714a53 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2132,21 +2132,6 @@ 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( @@ -3213,20 +3198,6 @@ 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 f20b7e0..2e6ee2c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2136,21 +2136,6 @@ 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( @@ -3210,19 +3195,6 @@ 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 d9e7645..ce454a3 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -81,7 +81,6 @@ class _AuditPageState extends State { /// filtering, and the hash-chain ordering exactly as before. StreamSubscription? _eventSub; Timer? _nudgeDebounce; - Timer? _reconnect; @override void initState() { @@ -98,7 +97,6 @@ class _AuditPageState extends State { Workspace.instance.removeListener(_onWorkspaceChanged); _poller?.cancel(); _nudgeDebounce?.cancel(); - _reconnect?.cancel(); _eventSub?.cancel(); super.dispose(); } @@ -124,12 +122,7 @@ class _AuditPageState extends State { onDone: () { _eventSub = null; if (mounted) { - // 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), () { + Timer(const Duration(seconds: 3), () { if (mounted && _eventSub == null) _subscribeLive(); }); } diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 32c6ae8..2079e36 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -7,7 +7,6 @@ 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'; @@ -76,49 +75,23 @@ 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, - onOpenStore: openStore, - onOpenApprovals: openApprovals, - onOpenAudit: openAudit, - ), + _SummaryStrip(snapshot: s), const SizedBox(height: ChainSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, - child: _EventLogPanel( - snapshot: s, - onRefresh: _refresh, - onOpenAudit: openAudit, - ), + child: _EventLogPanel(snapshot: s, onRefresh: _refresh), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of( context, )!.doctorModulesApprovalsSection, - child: DoctorModulesPanel( - snapshot: s, - onOpenStore: openStore, - onOpenApprovals: openApprovals, - ), + child: _ModulesPanel(snapshot: s), ), const SizedBox(height: ChainSpace.lg), _Section( @@ -177,32 +150,22 @@ 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, - this.onOpenStore, - this.onOpenApprovals, - this.onOpenAudit, - }); + const _SummaryStrip({required this.snapshot}); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ - DoctorStatTile( + _StatTile( 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), - DoctorStatTile( + _StatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, @@ -210,11 +173,9 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.pendingApprovals > 0 ? ChainPillTone.warning : ChainPillTone.neutral, - onTap: onOpenApprovals, - linkLabel: l.doctorLinkApprovals, ), const SizedBox(width: ChainSpace.md), - DoctorStatTile( + _StatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( @@ -225,11 +186,9 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.chainHealthy ? ChainPillTone.success : ChainPillTone.danger, - onTap: onOpenAudit, - linkLabel: l.doctorLinkAudit, ), const SizedBox(width: ChainSpace.md), - DoctorStatTile( + _StatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, @@ -240,32 +199,19 @@ class _SummaryStrip 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 { +class _StatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final ChainPillTone tone; - final VoidCallback? onTap; - /// Human-readable tap target ("Open approvals"). Required when - /// [onTap] is set; doubles as tooltip and semantics label. - final String? linkLabel; - - const DoctorStatTile({ - super.key, + const _StatTile({ required this.label, required this.value, required this.subtitle, required this.icon, this.tone = ChainPillTone.neutral, - this.onTap, - this.linkLabel, }); @override @@ -277,72 +223,49 @@ class DoctorStatTile extends StatelessWidget { ChainPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; - 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, + 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, + ), ), ), - ), - 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: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + 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, + ), + ), + ], + ), ), ); - 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); } } @@ -354,60 +277,13 @@ class _EventLogPanel extends StatelessWidget { /// after import / restore without restarting the daemon. final VoidCallback 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, - }); + const _EventLogPanel({required this.snapshot, required this.onRefresh}); @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: [ @@ -417,7 +293,32 @@ class _EventLogPanel extends StatelessWidget { color: healthy ? ChainColors.success : theme.colorScheme.error, ), const SizedBox(width: ChainSpace.lg), - Expanded(child: headline), + 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, + ), + ), + ], + ), + ), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), @@ -933,43 +834,10 @@ String _sourceKindLabel(String kind) { } } -/// 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 { +class _ModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; - final VoidCallback? onOpenStore; - final VoidCallback? onOpenApprovals; - 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), - ), - ), - ); - } + const _ModulesPanel({required this.snapshot}); @override Widget build(BuildContext context) { @@ -979,95 +847,69 @@ class DoctorModulesPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _linkRow( - context, - onTap: onOpenStore, - linkLabel: l.doctorLinkStore, - child: 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, + 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, ), - if (snapshot.capabilitiesBySource.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _sourceBreakdown(snapshot.capabilitiesBySource), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + 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, - ), - ], - ], - ), + ), + ChainPill( + label: snapshot.moduleCount > 0 + ? l.doctorPillLoaded + : l.doctorPillEmpty, + tone: snapshot.moduleCount > 0 + ? ChainPillTone.success + : ChainPillTone.neutral, + ), + ], ), const Divider(height: ChainSpace.xl), - _linkRow( - context, - onTap: onOpenApprovals, - linkLabel: l.doctorLinkApprovals, - child: Row( - children: [ - Icon( - Icons.inbox_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + 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, ), - 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, - ), - ], - ], - ), + ], ), ], ), @@ -1118,21 +960,6 @@ 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, - ), - ), ], ), ); @@ -1258,32 +1085,11 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), - // 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, - ), - ), - ), - ), + Text( + l.doctorReleaseNotes(status.releaseNotesUrl!), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.primary, ), ), ], diff --git a/lib/pages/runs.dart b/lib/pages/runs.dart index 64fdf57..6f458c0 100644 --- a/lib/pages/runs.dart +++ b/lib/pages/runs.dart @@ -3,11 +3,9 @@ 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; @@ -24,138 +22,10 @@ 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; - Object? _error; - RunsLoadIssue _issue = RunsLoadIssue.other; + String? _error; bool _loaded = false; Timer? _poll; final Set _cancelling = {}; @@ -193,8 +63,7 @@ class _RunsPageState extends State { } catch (e) { if (!mounted) return; setState(() { - _error = e; - _issue = classifyRunsLoadError(e); + _error = e.toString(); _loaded = true; }); } @@ -249,12 +118,11 @@ class _RunsPageState extends State { body: !_loaded ? const Center(child: CircularProgressIndicator()) : _error != null && _runs.isEmpty - ? RunsLoadErrorView( - error: _error!, - issue: _issue, - onOpenGuide: () => showFaiDoc(context, 'runs'), - onOpenDoctor: () => - StudioShellState.of(context)?.navigateTo('doctor'), + ? ChainEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: l.hubUnreachable, + hint: l.hubUnreachableHint, ) : _runs.isEmpty ? ChainEmptyState( diff --git a/pubspec.yaml b/pubspec.yaml index 1c8dcbc..362f75e 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.74.0 +version: 0.73.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/a11y_test.dart b/test/a11y_test.dart index 981e52b..3ef853a 100644 --- a/test/a11y_test.dart +++ b/test/a11y_test.dart @@ -88,19 +88,6 @@ 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 deleted file mode 100644 index 1b44597..0000000 --- a/test/doctor_deeplinks_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -// 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 deleted file mode 100644 index 0249aab..0000000 --- a/test/runs_error_classification_test.dart +++ /dev/null @@ -1,172 +0,0 @@ -// 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); - }); - }); -}