feat: detached-runs monitor page (T3 parity)
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
New Runs sidebar destination listing detached invocations (detach:true) with phase, current step, project and a Cancel button while pending/running. Workspace-scoped like Audit/Approvals, polls every 2s. Detached runs are opt-in (detached.enabled) — the empty state explains how to enable them. Inline help doc DE+EN. DetachedRun model + listDetachedRuns/cancelDetachedRun in HubService, backed by the SDK's listInvocations()/cancelInvocation(). flutter analyze clean; 29 tests green (sidebar Y-stability updated for the new destination, model mapping unit-tested). Screenshot verification (light+dark) deferred — shared desktop in use. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
984c91f91d
commit
54ccd3936a
13 changed files with 764 additions and 2 deletions
243
lib/pages/runs.dart
Normal file
243
lib/pages/runs.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
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 [];
|
||||
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 runs = await HubService.instance.listDetachedRuns(
|
||||
project: Workspace.instance.activeSlug,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_runs = runs;
|
||||
_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,
|
||||
hint: l.runsEmptyHint,
|
||||
)
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue