Compare commits
No commits in common. "adc5fc231106229289f50465828e608c293f937e" and "b47d8c4646eb3b7081aed8165fd883548c5147bd" have entirely different histories.
adc5fc2311
...
b47d8c4646
16 changed files with 146 additions and 969 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -6,31 +6,6 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
### Added
|
||||||
|
|
||||||
- **Full-history audit export.** The audit page's actions menu gains
|
- **Full-history audit export.** The audit page's actions menu gains
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.74.0';
|
const String kStudioVersion = '0.73.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -217,16 +217,6 @@ FriendlyError? _matchHubPattern(String detail, AppLocalizations l) {
|
||||||
return null;
|
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.
|
/// Try to read an `int` field by name off an arbitrary object.
|
||||||
/// Returns `null` when the field doesn't exist or has another
|
/// Returns `null` when the field doesn't exist or has another
|
||||||
/// runtime type. Used to duck-type `GrpcError.code` without
|
/// runtime type. Used to duck-type `GrpcError.code` without
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
// Methods return UI-friendly types so pages stay free of
|
// Methods return UI-friendly types so pages stay free of
|
||||||
// protobuf imports.
|
// protobuf imports.
|
||||||
|
|
||||||
import 'dart:async' show unawaited;
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
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
|
/// Reload the token from disk and reconnect using the current
|
||||||
/// endpoint. Called by Settings after the operator pastes or
|
/// endpoint. Called by Settings after the operator pastes or
|
||||||
/// clears a token.
|
/// clears a token.
|
||||||
|
|
|
||||||
|
|
@ -1184,11 +1184,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctorSummaryDeclared": "deklariert",
|
"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": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}",
|
||||||
"@doctorModulesPanelSummary": {
|
"@doctorModulesPanelSummary": {
|
||||||
"placeholders": {
|
"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.",
|
"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.",
|
"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",
|
"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",
|
"runsCancelButton": "Abbrechen",
|
||||||
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
||||||
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
|
|
||||||
|
|
@ -1208,11 +1208,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"doctorSummaryDeclared": "declared",
|
"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": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}",
|
||||||
"@doctorModulesPanelSummary": {
|
"@doctorModulesPanelSummary": {
|
||||||
"placeholders": {
|
"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.",
|
"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.",
|
"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",
|
"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",
|
"runsCancelButton": "Cancel",
|
||||||
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
||||||
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
||||||
|
|
|
||||||
|
|
@ -3662,36 +3662,6 @@ abstract class AppLocalizations {
|
||||||
/// **'declared'**
|
/// **'declared'**
|
||||||
String get doctorSummaryDeclared;
|
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.
|
/// No description provided for @doctorModulesPanelSummary.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5431,30 +5401,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Open the guide'**
|
/// **'Open the guide'**
|
||||||
String get runsEmptyGuideButton;
|
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.
|
/// No description provided for @runsCancelButton.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2132,21 +2132,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get doctorSummaryDeclared => 'deklariert';
|
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
|
@override
|
||||||
String doctorModulesPanelSummary(int n, int m) {
|
String doctorModulesPanelSummary(int n, int m) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
|
@ -3213,20 +3198,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Anleitung öffnen';
|
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
|
@override
|
||||||
String get runsCancelButton => 'Abbrechen';
|
String get runsCancelButton => 'Abbrechen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2136,21 +2136,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get doctorSummaryDeclared => 'declared';
|
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
|
@override
|
||||||
String doctorModulesPanelSummary(int n, int m) {
|
String doctorModulesPanelSummary(int n, int m) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
|
@ -3210,19 +3195,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Open the guide';
|
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
|
@override
|
||||||
String get runsCancelButton => 'Cancel';
|
String get runsCancelButton => 'Cancel';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
/// filtering, and the hash-chain ordering exactly as before.
|
/// filtering, and the hash-chain ordering exactly as before.
|
||||||
StreamSubscription<AuditEvent>? _eventSub;
|
StreamSubscription<AuditEvent>? _eventSub;
|
||||||
Timer? _nudgeDebounce;
|
Timer? _nudgeDebounce;
|
||||||
Timer? _reconnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -98,7 +97,6 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
Workspace.instance.removeListener(_onWorkspaceChanged);
|
Workspace.instance.removeListener(_onWorkspaceChanged);
|
||||||
_poller?.cancel();
|
_poller?.cancel();
|
||||||
_nudgeDebounce?.cancel();
|
_nudgeDebounce?.cancel();
|
||||||
_reconnect?.cancel();
|
|
||||||
_eventSub?.cancel();
|
_eventSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -124,12 +122,7 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
onDone: () {
|
onDone: () {
|
||||||
_eventSub = null;
|
_eventSub = null;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Held in a field so dispose() can cancel it — an
|
Timer(const Duration(seconds: 3), () {
|
||||||
// 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();
|
if (mounted && _eventSub == null) _subscribeLive();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import '../data/hub.dart';
|
||||||
import '../data/hub_auth_token.dart';
|
import '../data/hub_auth_token.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
|
|
@ -76,49 +75,23 @@ class _DoctorPageState extends State<DoctorPage> {
|
||||||
final showUpdate =
|
final showUpdate =
|
||||||
s.update.updateAvailable ||
|
s.update.updateAvailable ||
|
||||||
(!s.update.manifestReachable && s.update.localVersion.isNotEmpty);
|
(!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(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(ChainSpace.xl),
|
padding: const EdgeInsets.all(ChainSpace.xl),
|
||||||
children: [
|
children: [
|
||||||
if (showUpdate) _UpdateBanner(status: s.update),
|
if (showUpdate) _UpdateBanner(status: s.update),
|
||||||
if (showUpdate) const SizedBox(height: ChainSpace.lg),
|
if (showUpdate) const SizedBox(height: ChainSpace.lg),
|
||||||
_SummaryStrip(
|
_SummaryStrip(snapshot: s),
|
||||||
snapshot: s,
|
|
||||||
onOpenStore: openStore,
|
|
||||||
onOpenApprovals: openApprovals,
|
|
||||||
onOpenAudit: openAudit,
|
|
||||||
),
|
|
||||||
const SizedBox(height: ChainSpace.xl),
|
const SizedBox(height: ChainSpace.xl),
|
||||||
_Section(
|
_Section(
|
||||||
title: AppLocalizations.of(context)!.doctorEventLogSection,
|
title: AppLocalizations.of(context)!.doctorEventLogSection,
|
||||||
child: _EventLogPanel(
|
child: _EventLogPanel(snapshot: s, onRefresh: _refresh),
|
||||||
snapshot: s,
|
|
||||||
onRefresh: _refresh,
|
|
||||||
onOpenAudit: openAudit,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: ChainSpace.lg),
|
const SizedBox(height: ChainSpace.lg),
|
||||||
_Section(
|
_Section(
|
||||||
title: AppLocalizations.of(
|
title: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.doctorModulesApprovalsSection,
|
)!.doctorModulesApprovalsSection,
|
||||||
child: DoctorModulesPanel(
|
child: _ModulesPanel(snapshot: s),
|
||||||
snapshot: s,
|
|
||||||
onOpenStore: openStore,
|
|
||||||
onOpenApprovals: openApprovals,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: ChainSpace.lg),
|
const SizedBox(height: ChainSpace.lg),
|
||||||
_Section(
|
_Section(
|
||||||
|
|
@ -177,32 +150,22 @@ class _Section extends StatelessWidget {
|
||||||
|
|
||||||
class _SummaryStrip extends StatelessWidget {
|
class _SummaryStrip extends StatelessWidget {
|
||||||
final DoctorSnapshot snapshot;
|
final DoctorSnapshot snapshot;
|
||||||
final VoidCallback? onOpenStore;
|
|
||||||
final VoidCallback? onOpenApprovals;
|
|
||||||
final VoidCallback? onOpenAudit;
|
|
||||||
|
|
||||||
const _SummaryStrip({
|
const _SummaryStrip({required this.snapshot});
|
||||||
required this.snapshot,
|
|
||||||
this.onOpenStore,
|
|
||||||
this.onOpenApprovals,
|
|
||||||
this.onOpenAudit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
DoctorStatTile(
|
_StatTile(
|
||||||
label: l.doctorSummaryModules,
|
label: l.doctorSummaryModules,
|
||||||
value: snapshot.moduleCount.toString(),
|
value: snapshot.moduleCount.toString(),
|
||||||
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
|
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
|
||||||
icon: Icons.extension_outlined,
|
icon: Icons.extension_outlined,
|
||||||
onTap: onOpenStore,
|
|
||||||
linkLabel: l.doctorLinkStore,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
DoctorStatTile(
|
_StatTile(
|
||||||
label: l.doctorSummaryApprovals,
|
label: l.doctorSummaryApprovals,
|
||||||
value: snapshot.pendingApprovals.toString(),
|
value: snapshot.pendingApprovals.toString(),
|
||||||
subtitle: l.doctorSummaryPending,
|
subtitle: l.doctorSummaryPending,
|
||||||
|
|
@ -210,11 +173,9 @@ class _SummaryStrip extends StatelessWidget {
|
||||||
tone: snapshot.pendingApprovals > 0
|
tone: snapshot.pendingApprovals > 0
|
||||||
? ChainPillTone.warning
|
? ChainPillTone.warning
|
||||||
: ChainPillTone.neutral,
|
: ChainPillTone.neutral,
|
||||||
onTap: onOpenApprovals,
|
|
||||||
linkLabel: l.doctorLinkApprovals,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
DoctorStatTile(
|
_StatTile(
|
||||||
label: l.doctorSummaryAudit,
|
label: l.doctorSummaryAudit,
|
||||||
value: snapshot.chainHealthy ? '✓' : '⚠',
|
value: snapshot.chainHealthy ? '✓' : '⚠',
|
||||||
subtitle: l.doctorSummaryChain(
|
subtitle: l.doctorSummaryChain(
|
||||||
|
|
@ -225,11 +186,9 @@ class _SummaryStrip extends StatelessWidget {
|
||||||
tone: snapshot.chainHealthy
|
tone: snapshot.chainHealthy
|
||||||
? ChainPillTone.success
|
? ChainPillTone.success
|
||||||
: ChainPillTone.danger,
|
: ChainPillTone.danger,
|
||||||
onTap: onOpenAudit,
|
|
||||||
linkLabel: l.doctorLinkAudit,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
DoctorStatTile(
|
_StatTile(
|
||||||
label: l.doctorSummaryServices,
|
label: l.doctorSummaryServices,
|
||||||
value: snapshot.services.length.toString(),
|
value: snapshot.services.length.toString(),
|
||||||
subtitle: l.doctorSummaryDeclared,
|
subtitle: l.doctorSummaryDeclared,
|
||||||
|
|
@ -240,32 +199,19 @@ class _SummaryStrip extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One summary tile of the doctor page. Public + callback-driven so
|
class _StatTile extends StatelessWidget {
|
||||||
/// 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 label;
|
||||||
final String value;
|
final String value;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final ChainPillTone tone;
|
final ChainPillTone tone;
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
/// Human-readable tap target ("Open approvals"). Required when
|
const _StatTile({
|
||||||
/// [onTap] is set; doubles as tooltip and semantics label.
|
|
||||||
final String? linkLabel;
|
|
||||||
|
|
||||||
const DoctorStatTile({
|
|
||||||
super.key,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
this.tone = ChainPillTone.neutral,
|
this.tone = ChainPillTone.neutral,
|
||||||
this.onTap,
|
|
||||||
this.linkLabel,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -277,72 +223,49 @@ class DoctorStatTile extends StatelessWidget {
|
||||||
ChainPillTone.danger => theme.colorScheme.error,
|
ChainPillTone.danger => theme.colorScheme.error,
|
||||||
_ => theme.colorScheme.primary,
|
_ => theme.colorScheme.primary,
|
||||||
};
|
};
|
||||||
Widget card = ChainCard(
|
return Expanded(
|
||||||
accentLeft: accentColor,
|
child: ChainCard(
|
||||||
padding: const EdgeInsets.all(ChainSpace.lg),
|
accentLeft: accentColor,
|
||||||
child: Column(
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(width: ChainSpace.xs),
|
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||||
Flexible(
|
const SizedBox(width: ChainSpace.xs),
|
||||||
child: Text(
|
Flexible(
|
||||||
label.toUpperCase(),
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
label.toUpperCase(),
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
letterSpacing: 0.5,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
fontSize: 10,
|
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),
|
||||||
const SizedBox(height: 2),
|
Text(
|
||||||
Text(
|
value,
|
||||||
subtitle,
|
style: theme.textTheme.displaySmall?.copyWith(
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
color: accentColor,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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.
|
/// after import / restore without restarting the daemon.
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
/// Jumps to the audit page — the finding's detail view (per-event
|
const _EventLogPanel({required this.snapshot, required this.onRefresh});
|
||||||
/// list, forensic exports) lives there.
|
|
||||||
final VoidCallback? onOpenAudit;
|
|
||||||
|
|
||||||
const _EventLogPanel({
|
|
||||||
required this.snapshot,
|
|
||||||
required this.onRefresh,
|
|
||||||
this.onOpenAudit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final healthy = snapshot.chainHealthy;
|
final healthy = snapshot.chainHealthy;
|
||||||
final l = AppLocalizations.of(context)!;
|
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(
|
return ChainCard(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -417,7 +293,32 @@ class _EventLogPanel extends StatelessWidget {
|
||||||
color: healthy ? ChainColors.success : theme.colorScheme.error,
|
color: healthy ? ChainColors.success : theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.lg),
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: onRefresh,
|
onPressed: onRefresh,
|
||||||
icon: const Icon(Icons.fact_check_outlined, size: 16),
|
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
|
class _ModulesPanel extends StatelessWidget {
|
||||||
/// 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 DoctorSnapshot snapshot;
|
||||||
final VoidCallback? onOpenStore;
|
|
||||||
final VoidCallback? onOpenApprovals;
|
|
||||||
|
|
||||||
const DoctorModulesPanel({
|
const _ModulesPanel({required this.snapshot});
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -979,95 +847,69 @@ class DoctorModulesPanel extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_linkRow(
|
Row(
|
||||||
context,
|
children: [
|
||||||
onTap: onOpenStore,
|
Icon(
|
||||||
linkLabel: l.doctorLinkStore,
|
Icons.extension_outlined,
|
||||||
child: Row(
|
size: 18,
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
Icon(
|
),
|
||||||
Icons.extension_outlined,
|
const SizedBox(width: ChainSpace.sm),
|
||||||
size: 18,
|
Expanded(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: ChainSpace.sm),
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: Column(
|
l.doctorModulesPanelSummary(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
snapshot.moduleCount,
|
||||||
children: [
|
snapshot.capabilityCount,
|
||||||
Text(
|
|
||||||
l.doctorModulesPanelSummary(
|
|
||||||
snapshot.moduleCount,
|
|
||||||
snapshot.capabilityCount,
|
|
||||||
),
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
),
|
||||||
if (snapshot.capabilitiesBySource.isNotEmpty) ...[
|
style: theme.textTheme.bodyMedium,
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
Text(
|
if (snapshot.capabilitiesBySource.isNotEmpty) ...[
|
||||||
_sourceBreakdown(snapshot.capabilitiesBySource),
|
const SizedBox(height: 2),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
_sourceBreakdown(snapshot.capabilitiesBySource),
|
||||||
),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
ChainPill(
|
),
|
||||||
label: snapshot.moduleCount > 0
|
ChainPill(
|
||||||
? l.doctorPillLoaded
|
label: snapshot.moduleCount > 0
|
||||||
: l.doctorPillEmpty,
|
? l.doctorPillLoaded
|
||||||
tone: snapshot.moduleCount > 0
|
: l.doctorPillEmpty,
|
||||||
? ChainPillTone.success
|
tone: snapshot.moduleCount > 0
|
||||||
: ChainPillTone.neutral,
|
? 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),
|
const Divider(height: ChainSpace.xl),
|
||||||
_linkRow(
|
Row(
|
||||||
context,
|
children: [
|
||||||
onTap: onOpenApprovals,
|
Icon(
|
||||||
linkLabel: l.doctorLinkApprovals,
|
Icons.inbox_outlined,
|
||||||
child: Row(
|
size: 18,
|
||||||
children: [
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
Icon(
|
),
|
||||||
Icons.inbox_outlined,
|
const SizedBox(width: ChainSpace.sm),
|
||||||
size: 18,
|
Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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,
|
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) ...[
|
if (available && status.releaseNotesUrl != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// A URL the operator cannot click is a finding
|
Text(
|
||||||
// without a link — open it in the browser.
|
l.doctorReleaseNotes(status.releaseNotesUrl!),
|
||||||
Tooltip(
|
style: ChainTheme.mono(
|
||||||
message: l.doctorLinkReleaseNotes,
|
size: 11,
|
||||||
waitDuration: const Duration(milliseconds: 400),
|
color: theme.colorScheme.primary,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
import '../data/friendly_error.dart';
|
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import '../widgets/widgets.dart';
|
import '../widgets/widgets.dart';
|
||||||
import 'welcome.dart' show showFaiDoc;
|
import 'welcome.dart' show showFaiDoc;
|
||||||
|
|
@ -24,138 +22,10 @@ class RunsPage extends StatefulWidget {
|
||||||
State<RunsPage> createState() => _RunsPageState();
|
State<RunsPage> 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<RunsPage> {
|
class _RunsPageState extends State<RunsPage> {
|
||||||
List<DetachedRun> _runs = const [];
|
List<DetachedRun> _runs = const [];
|
||||||
bool _detachedEnabled = false;
|
bool _detachedEnabled = false;
|
||||||
Object? _error;
|
String? _error;
|
||||||
RunsLoadIssue _issue = RunsLoadIssue.other;
|
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
Timer? _poll;
|
Timer? _poll;
|
||||||
final Set<String> _cancelling = <String>{};
|
final Set<String> _cancelling = <String>{};
|
||||||
|
|
@ -193,8 +63,7 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e;
|
_error = e.toString();
|
||||||
_issue = classifyRunsLoadError(e);
|
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -249,12 +118,11 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
body: !_loaded
|
body: !_loaded
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null && _runs.isEmpty
|
: _error != null && _runs.isEmpty
|
||||||
? RunsLoadErrorView(
|
? ChainEmptyState(
|
||||||
error: _error!,
|
icon: Icons.cloud_off_outlined,
|
||||||
issue: _issue,
|
iconColor: theme.colorScheme.error,
|
||||||
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
title: l.hubUnreachable,
|
||||||
onOpenDoctor: () =>
|
hint: l.hubUnreachableHint,
|
||||||
StudioShellState.of(context)?.navigateTo('doctor'),
|
|
||||||
)
|
)
|
||||||
: _runs.isEmpty
|
: _runs.isEmpty
|
||||||
? ChainEmptyState(
|
? ChainEmptyState(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.74.0
|
version: 0.73.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
|
|
@ -88,19 +88,6 @@ void main() {
|
||||||
// !timersPending after the test body.
|
// !timersPending after the test body.
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
await tester.pump(const Duration(minutes: 1));
|
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<void>.delayed(const Duration(milliseconds: 100));
|
|
||||||
});
|
|
||||||
await tester.pump(const Duration(minutes: 6));
|
|
||||||
expect(
|
expect(
|
||||||
violations,
|
violations,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue