fix(runs): stop claiming the hub is unreachable on RPC-level errors
The runs monitor folded every load failure into 'hub not reachable', contradicting the sidebar's green connected dot whenever the hub answered but the RPC failed — most visibly against a pre-0.22 hub whose version predates the ListInvocations RPC (UNIMPLEMENTED). Classify the failure instead (top-level, unit-tested): - UNIMPLEMENTED -> 'this view needs a newer hub version' with a doctor-page link (the update banner lives there) - FAILED_PRECONDITION from the detached gate -> the regular feature-off empty state with the guide button - UNAVAILABLE / DEADLINE_EXCEEDED / socket-level failures -> the honest 'hub not reachable' state (unchanged) - everything else -> a load-failed state with the friendly error and a copyable detail box The error view is a public callback-driven widget so the tests pump each variant without a live hub. New DE+EN strings for the too-old and load-failed states; grpcCodeOf/grpcMessageOf exposed from the friendly-error mapper instead of duplicating the duck-typing. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
b47d8c4646
commit
ea2cd87b5e
8 changed files with 380 additions and 7 deletions
|
|
@ -3,9 +3,11 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/error_presentation.dart';
|
||||
import '../data/friendly_error.dart';
|
||||
import '../data/hub.dart';
|
||||
import '../data/workspace.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../main.dart' show StudioShellState;
|
||||
import '../theme/tokens.dart';
|
||||
import '../widgets/widgets.dart';
|
||||
import 'welcome.dart' show showFaiDoc;
|
||||
|
|
@ -22,10 +24,138 @@ class RunsPage extends StatefulWidget {
|
|||
State<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> {
|
||||
List<DetachedRun> _runs = const [];
|
||||
bool _detachedEnabled = false;
|
||||
String? _error;
|
||||
Object? _error;
|
||||
RunsLoadIssue _issue = RunsLoadIssue.other;
|
||||
bool _loaded = false;
|
||||
Timer? _poll;
|
||||
final Set<String> _cancelling = <String>{};
|
||||
|
|
@ -63,7 +193,8 @@ class _RunsPageState extends State<RunsPage> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = e;
|
||||
_issue = classifyRunsLoadError(e);
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
|
@ -118,11 +249,12 @@ class _RunsPageState extends State<RunsPage> {
|
|||
body: !_loaded
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null && _runs.isEmpty
|
||||
? ChainEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
? RunsLoadErrorView(
|
||||
error: _error!,
|
||||
issue: _issue,
|
||||
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
||||
onOpenDoctor: () =>
|
||||
StudioShellState.of(context)?.navigateTo('doctor'),
|
||||
)
|
||||
: _runs.isEmpty
|
||||
? ChainEmptyState(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue