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
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added (detached-runs monitor — T3 parity)
|
||||
|
||||
- **Runs page.** A new sidebar destination lists detached invocations
|
||||
(submitted with `detach: true`) with their phase, current step,
|
||||
project and a **Cancel** button while pending/running. Workspace-
|
||||
scoped like Audit and Approvals; polls every 2 s. Detached runs are
|
||||
opt-in (`detached.enabled`), so the empty state explains how to turn
|
||||
them on. Inline help doc (DE+EN). Backed by the SDK's
|
||||
`listInvocations()` + `cancelInvocation()`.
|
||||
|
||||
### Added (multi-project, stages ① + ②)
|
||||
|
||||
- **Workspace switcher.** The Audit and Approvals AppBars carry a
|
||||
|
|
|
|||
45
assets/docs/runs.md
Normal file
45
assets/docs/runs.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Runs
|
||||
|
||||
The Runs page monitors **detached invocations** — flows submitted
|
||||
to run in the background rather than being waited on. Each row shows
|
||||
the flow, its current phase, the step it is on, and the project it
|
||||
was stamped with.
|
||||
|
||||
## What a detached run is
|
||||
|
||||
Most flows run *inline*: you start them and wait for the result. A
|
||||
**detached** run is handed to the hub and returns an id immediately;
|
||||
the flow keeps running in the background. You come back later to
|
||||
read its result, follow it live, or cancel it.
|
||||
|
||||
Detached invocations are **opt-in**. They only work when the
|
||||
operator enabled them in the hub config:
|
||||
|
||||
```yaml
|
||||
detached:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Until then this page is empty — that is the normal state.
|
||||
|
||||
## Phases
|
||||
|
||||
- **Pending** — accepted, not yet executing.
|
||||
- **Running** — a step is executing (the row names it).
|
||||
- **Succeeded** — finished; the result is retained under the hub's
|
||||
size/count/TTL limits.
|
||||
- **Failed** — finished with an error (shown on the row).
|
||||
- **Cancelled** — stopped by an operator.
|
||||
|
||||
## Cancelling
|
||||
|
||||
A pending or running detached run carries a **Cancel** button.
|
||||
Cancelling signals the run to stop; a run that already finished
|
||||
cannot be cancelled (Studio says so rather than pretending).
|
||||
|
||||
## Workspace scope
|
||||
|
||||
The page respects the active workspace: with a project selected it
|
||||
shows only that project's detached runs. Detached results are held
|
||||
in memory for the current hub process — they are not retained across
|
||||
a hub restart.
|
||||
48
assets/docs/runs_de.md
Normal file
48
assets/docs/runs_de.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Läufe
|
||||
|
||||
Die Läufe-Seite überwacht **abgekoppelte Aufrufe** — Flows, die im
|
||||
Hintergrund laufen, statt auf ihr Ergebnis zu warten. Jede Zeile
|
||||
zeigt den Flow, seine Phase, den aktuellen Schritt und das Projekt,
|
||||
mit dem er gestempelt wurde.
|
||||
|
||||
## Was ein abgekoppelter Lauf ist
|
||||
|
||||
Die meisten Flows laufen *inline*: Man startet sie und wartet auf
|
||||
das Ergebnis. Ein **abgekoppelter** Lauf wird dem Hub übergeben und
|
||||
liefert sofort eine Kennung zurück; der Flow läuft im Hintergrund
|
||||
weiter. Man kommt später zurück, um das Ergebnis zu lesen, dem Lauf
|
||||
live zu folgen oder ihn abzubrechen.
|
||||
|
||||
Abgekoppelte Aufrufe sind **optional**. Sie funktionieren nur, wenn
|
||||
der Betreiber sie in der Hub-Konfiguration aktiviert hat:
|
||||
|
||||
```yaml
|
||||
detached:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Bis dahin ist diese Seite leer — das ist der Normalzustand.
|
||||
|
||||
## Phasen
|
||||
|
||||
- **Wartet** — angenommen, noch nicht in Ausführung.
|
||||
- **Läuft** — ein Schritt wird ausgeführt (die Zeile nennt ihn).
|
||||
- **Erfolgreich** — fertig; das Ergebnis wird unter den
|
||||
Größen-/Anzahl-/TTL-Grenzen des Hubs aufbewahrt.
|
||||
- **Fehlgeschlagen** — mit einem Fehler beendet (in der Zeile
|
||||
angezeigt).
|
||||
- **Abgebrochen** — von einem Betreiber gestoppt.
|
||||
|
||||
## Abbrechen
|
||||
|
||||
Ein wartender oder laufender abgekoppelter Lauf trägt einen
|
||||
**Abbrechen**-Knopf. Der Abbruch signalisiert dem Lauf zu stoppen;
|
||||
ein bereits beendeter Lauf lässt sich nicht abbrechen (Studio sagt
|
||||
das ehrlich, statt es vorzutäuschen).
|
||||
|
||||
## Arbeitsbereich-Filter
|
||||
|
||||
Die Seite berücksichtigt den aktiven Arbeitsbereich: Mit gewähltem
|
||||
Projekt zeigt sie nur dessen abgekoppelte Läufe. Abgekoppelte
|
||||
Ergebnisse werden im Speicher des laufenden Hub-Prozesses gehalten —
|
||||
sie überstehen keinen Hub-Neustart.
|
||||
|
|
@ -1017,6 +1017,23 @@ class HubService {
|
|||
Future<void> reject(String id, String reviewer, String reason) =>
|
||||
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
||||
|
||||
/// Every tracked detached invocation (newest-first). Optionally
|
||||
/// scoped to one [project]. Empty when detached invocations are
|
||||
/// disabled or none have run this process.
|
||||
Future<List<DetachedRun>> listDetachedRuns({String project = ''}) async {
|
||||
final entries = await _client.listInvocations();
|
||||
final runs = entries
|
||||
.where((e) => project.isEmpty || e.project == project)
|
||||
.map(DetachedRun.fromEntry)
|
||||
.toList();
|
||||
return runs;
|
||||
}
|
||||
|
||||
/// Cancel a running/pending detached invocation. Returns true when
|
||||
/// it was signalled, false if already finished or unknown.
|
||||
Future<bool> cancelDetachedRun(String id) =>
|
||||
_client.cancelInvocation(id);
|
||||
|
||||
/// Federation satellites currently connected to this hub
|
||||
/// (primary side). Empty when none are connected.
|
||||
Future<List<Satellite>> listSatellites() async {
|
||||
|
|
@ -1547,6 +1564,73 @@ class ApprovalRecord {
|
|||
});
|
||||
}
|
||||
|
||||
/// Lifecycle phase of a detached run, mirroring the wire enum.
|
||||
enum DetachedPhase { pending, running, succeeded, failed, cancelled, unknown }
|
||||
|
||||
/// A detached invocation as shown in the runs monitor.
|
||||
class DetachedRun {
|
||||
final String id;
|
||||
final DetachedPhase phase;
|
||||
final String flowName;
|
||||
final String project;
|
||||
|
||||
/// Step currently executing (running), else empty.
|
||||
final String currentStep;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? finishedAt;
|
||||
|
||||
/// Error message when [phase] is failed, else empty.
|
||||
final String error;
|
||||
|
||||
const DetachedRun({
|
||||
required this.id,
|
||||
required this.phase,
|
||||
required this.flowName,
|
||||
required this.project,
|
||||
required this.currentStep,
|
||||
required this.startedAt,
|
||||
required this.finishedAt,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
/// True while the run can still be cancelled.
|
||||
bool get isCancellable =>
|
||||
phase == DetachedPhase.pending || phase == DetachedPhase.running;
|
||||
|
||||
factory DetachedRun.fromEntry(InvocationEntry e) {
|
||||
// Map by the proto enum's integer value (PENDING=1 … CANCELLED=5)
|
||||
// so Studio needn't import the generated enum type.
|
||||
DetachedPhase mapPhase(int v) {
|
||||
switch (v) {
|
||||
case 1:
|
||||
return DetachedPhase.pending;
|
||||
case 2:
|
||||
return DetachedPhase.running;
|
||||
case 3:
|
||||
return DetachedPhase.succeeded;
|
||||
case 4:
|
||||
return DetachedPhase.failed;
|
||||
case 5:
|
||||
return DetachedPhase.cancelled;
|
||||
default:
|
||||
return DetachedPhase.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
final s = e.status;
|
||||
return DetachedRun(
|
||||
id: e.invocationId,
|
||||
phase: mapPhase(s.phase.value),
|
||||
flowName: e.flowName,
|
||||
project: e.project,
|
||||
currentStep: s.currentStep,
|
||||
startedAt: s.startedAt.isEmpty ? null : DateTime.tryParse(s.startedAt),
|
||||
finishedAt: s.finishedAt.isEmpty ? null : DateTime.tryParse(s.finishedAt),
|
||||
error: s.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SystemAiStatus {
|
||||
final bool enabled;
|
||||
final String provider;
|
||||
|
|
|
|||
|
|
@ -1667,5 +1667,22 @@
|
|||
"federationEnrollmentHint": "Übergib das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte CA authentifiziert den ersten Connect des Satelliten.",
|
||||
"workspaceAll": "Alle Projekte",
|
||||
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt",
|
||||
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich."
|
||||
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
|
||||
"runsTitle": "Läufe",
|
||||
"runsReloadTooltip": "Lauf-Liste neu laden",
|
||||
"runsEmptyTitle": "Keine abgekoppelten Läufe",
|
||||
"runsEmptyHint": "Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.",
|
||||
"runsCancelButton": "Abbrechen",
|
||||
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
||||
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
||||
"runsCancelTooLate": "Der Lauf war schon fertig — nichts abzubrechen.",
|
||||
"runsCurrentStep": "Schritt: {step}",
|
||||
"@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}},
|
||||
"runsPhasePending": "Wartet",
|
||||
"runsPhaseRunning": "Läuft",
|
||||
"runsPhaseSucceeded": "Erfolgreich",
|
||||
"runsPhaseFailed": "Fehlgeschlagen",
|
||||
"runsPhaseCancelled": "Abgebrochen",
|
||||
"runsPhaseUnknown": "Unbekannt",
|
||||
"navRuns": "Läufe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1706,5 +1706,22 @@
|
|||
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
|
||||
"workspaceAll": "All projects",
|
||||
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project",
|
||||
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area."
|
||||
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
|
||||
"runsTitle": "Runs",
|
||||
"runsReloadTooltip": "Reload the runs list",
|
||||
"runsEmptyTitle": "No detached runs",
|
||||
"runsEmptyHint": "Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.",
|
||||
"runsCancelButton": "Cancel",
|
||||
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
||||
"@runsCancelSignalled": {"placeholders": {"flow": {"type": "String"}}},
|
||||
"runsCancelTooLate": "The run had already finished — nothing to cancel.",
|
||||
"runsCurrentStep": "Step: {step}",
|
||||
"@runsCurrentStep": {"placeholders": {"step": {"type": "String"}}},
|
||||
"runsPhasePending": "Pending",
|
||||
"runsPhaseRunning": "Running",
|
||||
"runsPhaseSucceeded": "Succeeded",
|
||||
"runsPhaseFailed": "Failed",
|
||||
"runsPhaseCancelled": "Cancelled",
|
||||
"runsPhaseUnknown": "Unknown",
|
||||
"navRuns": "Runs"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4908,6 +4908,96 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.'**
|
||||
String get workspaceProtectedHint;
|
||||
|
||||
/// No description provided for @runsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Runs'**
|
||||
String get runsTitle;
|
||||
|
||||
/// No description provided for @runsReloadTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reload the runs list'**
|
||||
String get runsReloadTooltip;
|
||||
|
||||
/// No description provided for @runsEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No detached runs'**
|
||||
String get runsEmptyTitle;
|
||||
|
||||
/// No description provided for @runsEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.'**
|
||||
String get runsEmptyHint;
|
||||
|
||||
/// No description provided for @runsCancelButton.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get runsCancelButton;
|
||||
|
||||
/// No description provided for @runsCancelSignalled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel signalled for {flow}.'**
|
||||
String runsCancelSignalled(String flow);
|
||||
|
||||
/// No description provided for @runsCancelTooLate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The run had already finished — nothing to cancel.'**
|
||||
String get runsCancelTooLate;
|
||||
|
||||
/// No description provided for @runsCurrentStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Step: {step}'**
|
||||
String runsCurrentStep(String step);
|
||||
|
||||
/// No description provided for @runsPhasePending.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pending'**
|
||||
String get runsPhasePending;
|
||||
|
||||
/// No description provided for @runsPhaseRunning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Running'**
|
||||
String get runsPhaseRunning;
|
||||
|
||||
/// No description provided for @runsPhaseSucceeded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Succeeded'**
|
||||
String get runsPhaseSucceeded;
|
||||
|
||||
/// No description provided for @runsPhaseFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed'**
|
||||
String get runsPhaseFailed;
|
||||
|
||||
/// No description provided for @runsPhaseCancelled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancelled'**
|
||||
String get runsPhaseCancelled;
|
||||
|
||||
/// No description provided for @runsPhaseUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown'**
|
||||
String get runsPhaseUnknown;
|
||||
|
||||
/// No description provided for @navRuns.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Runs'**
|
||||
String get navRuns;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -2882,4 +2882,55 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get workspaceProtectedHint =>
|
||||
'Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.';
|
||||
|
||||
@override
|
||||
String get runsTitle => 'Läufe';
|
||||
|
||||
@override
|
||||
String get runsReloadTooltip => 'Lauf-Liste neu laden';
|
||||
|
||||
@override
|
||||
String get runsEmptyTitle => 'Keine abgekoppelten Läufe';
|
||||
|
||||
@override
|
||||
String get runsEmptyHint =>
|
||||
'Mit detach gestartete Läufe stehen hier, bis sie fertig sind. Abgekoppelte Aufrufe sind optional — dazu detached.enabled in der Betreiber-Konfiguration aktivieren.';
|
||||
|
||||
@override
|
||||
String get runsCancelButton => 'Abbrechen';
|
||||
|
||||
@override
|
||||
String runsCancelSignalled(String flow) {
|
||||
return 'Abbruch für $flow ausgelöst.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsCancelTooLate =>
|
||||
'Der Lauf war schon fertig — nichts abzubrechen.';
|
||||
|
||||
@override
|
||||
String runsCurrentStep(String step) {
|
||||
return 'Schritt: $step';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsPhasePending => 'Wartet';
|
||||
|
||||
@override
|
||||
String get runsPhaseRunning => 'Läuft';
|
||||
|
||||
@override
|
||||
String get runsPhaseSucceeded => 'Erfolgreich';
|
||||
|
||||
@override
|
||||
String get runsPhaseFailed => 'Fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get runsPhaseCancelled => 'Abgebrochen';
|
||||
|
||||
@override
|
||||
String get runsPhaseUnknown => 'Unbekannt';
|
||||
|
||||
@override
|
||||
String get navRuns => 'Läufe';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2885,4 +2885,55 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get workspaceProtectedHint =>
|
||||
'Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.';
|
||||
|
||||
@override
|
||||
String get runsTitle => 'Runs';
|
||||
|
||||
@override
|
||||
String get runsReloadTooltip => 'Reload the runs list';
|
||||
|
||||
@override
|
||||
String get runsEmptyTitle => 'No detached runs';
|
||||
|
||||
@override
|
||||
String get runsEmptyHint =>
|
||||
'Runs submitted with detach stay here until they finish. Detached invocations are opt-in — enable detached.enabled in the operator config to use them.';
|
||||
|
||||
@override
|
||||
String get runsCancelButton => 'Cancel';
|
||||
|
||||
@override
|
||||
String runsCancelSignalled(String flow) {
|
||||
return 'Cancel signalled for $flow.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsCancelTooLate =>
|
||||
'The run had already finished — nothing to cancel.';
|
||||
|
||||
@override
|
||||
String runsCurrentStep(String step) {
|
||||
return 'Step: $step';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runsPhasePending => 'Pending';
|
||||
|
||||
@override
|
||||
String get runsPhaseRunning => 'Running';
|
||||
|
||||
@override
|
||||
String get runsPhaseSucceeded => 'Succeeded';
|
||||
|
||||
@override
|
||||
String get runsPhaseFailed => 'Failed';
|
||||
|
||||
@override
|
||||
String get runsPhaseCancelled => 'Cancelled';
|
||||
|
||||
@override
|
||||
String get runsPhaseUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get navRuns => 'Runs';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import 'pages/audit.dart';
|
|||
import 'pages/doctor.dart';
|
||||
import 'pages/federation.dart';
|
||||
import 'pages/flows.dart';
|
||||
import 'pages/runs.dart';
|
||||
import 'pages/store.dart';
|
||||
import 'pages/welcome.dart';
|
||||
import 'theme/theme.dart';
|
||||
|
|
@ -380,6 +381,12 @@ class StudioShellState extends State<StudioShell> {
|
|||
selectedIcon: Icons.inbox,
|
||||
page: ApprovalsPage(),
|
||||
),
|
||||
_NavPage(
|
||||
id: 'runs',
|
||||
icon: Icons.rocket_launch_outlined,
|
||||
selectedIcon: Icons.rocket_launch,
|
||||
page: RunsPage(),
|
||||
),
|
||||
_NavPage(
|
||||
id: 'federation',
|
||||
icon: Icons.hub_outlined,
|
||||
|
|
@ -1641,6 +1648,8 @@ class _NavPage {
|
|||
return l.navAudit;
|
||||
case 'approvals':
|
||||
return l.navApprovals;
|
||||
case 'runs':
|
||||
return l.navRuns;
|
||||
case 'federation':
|
||||
return l.navFederation;
|
||||
default:
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
test/detached_run_test.dart
Normal file
96
test/detached_run_test.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// DetachedRun model — the phase mapping + cancellable predicate that
|
||||
// drive the runs monitor's chip tone and the cancel-button visibility.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chain_studio/data/hub.dart';
|
||||
// The generated enum type isn't re-exported by the SDK's public
|
||||
// surface (only the message typedefs are), so build fixtures against
|
||||
// the generated file directly — test-only.
|
||||
import 'package:chain_client_sdk/src/generated/chain/v1/hub.pb.dart';
|
||||
|
||||
InvocationEntry _entry({
|
||||
required String id,
|
||||
required int phaseValue,
|
||||
String flow = 'demo',
|
||||
String project = 'general',
|
||||
String step = '',
|
||||
String error = '',
|
||||
}) {
|
||||
final status = InvocationStatus()
|
||||
..phase = InvocationStatus_Phase.valueOf(phaseValue)!
|
||||
..currentStep = step
|
||||
..startedAt = '2026-07-12T10:00:00Z'
|
||||
..error = error;
|
||||
return InvocationEntry()
|
||||
..invocationId = id
|
||||
..flowName = flow
|
||||
..project = project
|
||||
..status = status;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('maps each wire phase to the model phase', () {
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).phase,
|
||||
DetachedPhase.pending,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).phase,
|
||||
DetachedPhase.running,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'c', phaseValue: 3)).phase,
|
||||
DetachedPhase.succeeded,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'd', phaseValue: 4)).phase,
|
||||
DetachedPhase.failed,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'e', phaseValue: 5)).phase,
|
||||
DetachedPhase.cancelled,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'f', phaseValue: 0)).phase,
|
||||
DetachedPhase.unknown,
|
||||
);
|
||||
});
|
||||
|
||||
test('only pending and running runs are cancellable', () {
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'a', phaseValue: 1)).isCancellable,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'b', phaseValue: 2)).isCancellable,
|
||||
isTrue,
|
||||
);
|
||||
for (final terminal in [3, 4, 5]) {
|
||||
expect(
|
||||
DetachedRun.fromEntry(_entry(id: 'x', phaseValue: terminal))
|
||||
.isCancellable,
|
||||
isFalse,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('carries flow name, project, step and error through', () {
|
||||
final run = DetachedRun.fromEntry(
|
||||
_entry(
|
||||
id: 'inv-1',
|
||||
phaseValue: 4,
|
||||
flow: 'intake',
|
||||
project: 'client-a',
|
||||
step: 'extract',
|
||||
error: 'boom',
|
||||
),
|
||||
);
|
||||
expect(run.id, 'inv-1');
|
||||
expect(run.flowName, 'intake');
|
||||
expect(run.project, 'client-a');
|
||||
expect(run.currentStep, 'extract');
|
||||
expect(run.error, 'boom');
|
||||
expect(run.startedAt, isNotNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ const _destinations = <String>[
|
|||
'flows',
|
||||
'audit',
|
||||
'approvals',
|
||||
'runs',
|
||||
'federation',
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue