Some checks are pending
Security / Security check (push) Waiting to run
The empty state unconditionally claimed background runs were switched off, even on hubs where the operator had enabled them. The hub now reports the flag on the list RPC (detached_enabled); the hint picks the truthful variant. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
256 lines
8.1 KiB
Dart
256 lines
8.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/error_presentation.dart';
|
|
import '../data/hub.dart';
|
|
import '../data/workspace.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/tokens.dart';
|
|
import '../widgets/widgets.dart';
|
|
import 'welcome.dart' show showFaiDoc;
|
|
|
|
/// Detached-runs monitor (T3 parity): the invocations submitted with
|
|
/// `detach: true`, with a cancel button while they are still running.
|
|
/// Workspace-scoped like Audit and Approvals. Detached invocations are
|
|
/// opt-in (`detached.enabled`), so an empty list is the normal case
|
|
/// for most operators — the empty state says so plainly.
|
|
class RunsPage extends StatefulWidget {
|
|
const RunsPage({super.key});
|
|
|
|
@override
|
|
State<RunsPage> createState() => _RunsPageState();
|
|
}
|
|
|
|
class _RunsPageState extends State<RunsPage> {
|
|
List<DetachedRun> _runs = const [];
|
|
bool _detachedEnabled = false;
|
|
String? _error;
|
|
bool _loaded = false;
|
|
Timer? _poll;
|
|
final Set<String> _cancelling = <String>{};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
Workspace.instance.addListener(_refresh);
|
|
Workspace.instance.ensureLoaded();
|
|
_refresh();
|
|
// A run's phase changes without user action, so poll — same 2 s
|
|
// tick the Audit page uses.
|
|
_poll = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
Workspace.instance.removeListener(_refresh);
|
|
_poll?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
try {
|
|
final snapshot = await HubService.instance.listDetachedRuns(
|
|
project: Workspace.instance.activeSlug,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_runs = snapshot.runs;
|
|
_detachedEnabled = snapshot.enabled;
|
|
_error = null;
|
|
_loaded = true;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
_loaded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _cancel(DetachedRun run) async {
|
|
final l = AppLocalizations.of(context)!;
|
|
setState(() => _cancelling.add(run.id));
|
|
try {
|
|
final ok = await HubService.instance.cancelDetachedRun(run.id);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
ok ? l.runsCancelSignalled(run.flowName) : l.runsCancelTooLate,
|
|
),
|
|
),
|
|
);
|
|
await _refresh();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showChainErrorSnack(context, 'runs.cancel', e);
|
|
} finally {
|
|
if (mounted) setState(() => _cancelling.remove(run.id));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
appBar: AppBar(
|
|
title: Text(l.runsTitle),
|
|
actions: [
|
|
const ChainWorkspaceSwitcher(),
|
|
const SizedBox(width: ChainSpace.md),
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline, size: 18),
|
|
tooltip: l.helpTooltip,
|
|
onPressed: () => showFaiDoc(context, 'runs'),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 18),
|
|
tooltip: l.runsReloadTooltip,
|
|
onPressed: _refresh,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
],
|
|
),
|
|
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,
|
|
)
|
|
: _runs.isEmpty
|
|
? ChainEmptyState(
|
|
icon: Icons.rocket_launch_outlined,
|
|
title: l.runsEmptyTitle,
|
|
// The hub reports whether the operator enabled the
|
|
// feature — never claim "switched off" while it is on
|
|
// and there simply are no runs yet (usertest finding).
|
|
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
|
|
// The guide carries the plain-language explanation plus
|
|
// the exact operator steps (config snippet) — a click
|
|
// target instead of a raw config key in the hint.
|
|
action: OutlinedButton.icon(
|
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
|
label: Text(l.runsEmptyGuideButton),
|
|
onPressed: () => showFaiDoc(context, 'runs'),
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
|
itemCount: _runs.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.sm),
|
|
itemBuilder: (context, i) => _RunRow(
|
|
run: _runs[i],
|
|
cancelling: _cancelling.contains(_runs[i].id),
|
|
onCancel: () => _cancel(_runs[i]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RunRow extends StatelessWidget {
|
|
final DetachedRun run;
|
|
final bool cancelling;
|
|
final VoidCallback onCancel;
|
|
|
|
const _RunRow({
|
|
required this.run,
|
|
required this.cancelling,
|
|
required this.onCancel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final (tone, label) = _phaseChip(l);
|
|
return ChainCard(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
run.flowName.isEmpty ? run.id : run.flowName,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
ChainPill(label: label, tone: tone),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_subtitle(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (run.error.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
SelectableText(
|
|
run.error,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (run.isCancellable)
|
|
cancelling
|
|
? const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
: OutlinedButton.icon(
|
|
onPressed: onCancel,
|
|
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
|
label: Text(l.runsCancelButton),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _subtitle(AppLocalizations l) {
|
|
final parts = <String>[];
|
|
if (run.currentStep.isNotEmpty) {
|
|
parts.add(l.runsCurrentStep(run.currentStep));
|
|
}
|
|
if (run.project.isNotEmpty) parts.add(run.project);
|
|
parts.add(run.id);
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
(ChainPillTone, String) _phaseChip(AppLocalizations l) {
|
|
switch (run.phase) {
|
|
case DetachedPhase.pending:
|
|
return (ChainPillTone.neutral, l.runsPhasePending);
|
|
case DetachedPhase.running:
|
|
return (ChainPillTone.accent, l.runsPhaseRunning);
|
|
case DetachedPhase.succeeded:
|
|
return (ChainPillTone.success, l.runsPhaseSucceeded);
|
|
case DetachedPhase.failed:
|
|
return (ChainPillTone.danger, l.runsPhaseFailed);
|
|
case DetachedPhase.cancelled:
|
|
return (ChainPillTone.warning, l.runsPhaseCancelled);
|
|
case DetachedPhase.unknown:
|
|
return (ChainPillTone.neutral, l.runsPhaseUnknown);
|
|
}
|
|
}
|
|
}
|