Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend. Implements CQRS architecture, OpenAPI contract-first API design. BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama FRONTEND: Cross-platform UI with strict typing and Result-based error handling Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
665 lines
19 KiB
Dart
665 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:iconsax/iconsax.dart';
|
|
import 'package:animate_do/animate_do.dart';
|
|
import 'package:timeago/timeago.dart' as timeago;
|
|
import '../api/api.dart';
|
|
|
|
/// Executions dashboard page
|
|
///
|
|
/// Displays all agent executions with filtering by status, metrics, and details.
|
|
/// Integrates with backend CQRS API for execution monitoring and analysis.
|
|
class ExecutionsPage extends StatefulWidget {
|
|
const ExecutionsPage({super.key});
|
|
|
|
@override
|
|
State<ExecutionsPage> createState() => _ExecutionsPageState();
|
|
}
|
|
|
|
class _ExecutionsPageState extends State<ExecutionsPage> {
|
|
final CqrsApiClient _apiClient = CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
);
|
|
|
|
List<ExecutionListItemDto>? _executions;
|
|
bool _isLoading = true;
|
|
String? _errorMessage;
|
|
ExecutionStatus? _filterStatus; // null = all, or specific status
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadExecutions();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_apiClient.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadExecutions() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final Result<List<ExecutionListItemDto>> result = _filterStatus == null
|
|
? await _apiClient.listExecutions()
|
|
: await _apiClient.listExecutionsByStatus(_filterStatus!);
|
|
|
|
result.when(
|
|
success: (List<ExecutionListItemDto> executions) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_executions = executions;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
},
|
|
error: (ApiErrorInfo error) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = error.message;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Map<String, int> get _statusCounts {
|
|
if (_executions == null) return {};
|
|
|
|
final Map<String, int> counts = {};
|
|
for (final ExecutionListItemDto exec in _executions!) {
|
|
counts[exec.status.value] = (counts[exec.status.value] ?? 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
double get _totalCost {
|
|
if (_executions == null) return 0.0;
|
|
|
|
return _executions!.fold<double>(
|
|
0.0,
|
|
(double sum, ExecutionListItemDto exec) =>
|
|
sum + (exec.estimatedCost ?? 0.0),
|
|
);
|
|
}
|
|
|
|
int get _totalTokens {
|
|
if (_executions == null) return 0;
|
|
|
|
return _executions!.fold<int>(
|
|
0,
|
|
(int sum, ExecutionListItemDto exec) =>
|
|
sum + (exec.inputTokens ?? 0) + (exec.outputTokens ?? 0),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header with actions
|
|
_buildHeader(colorScheme),
|
|
const SizedBox(height: 24),
|
|
|
|
// Metrics cards
|
|
_buildMetricsCards(colorScheme),
|
|
const SizedBox(height: 24),
|
|
|
|
// Status filter chips
|
|
_buildStatusFilters(colorScheme),
|
|
const SizedBox(height: 24),
|
|
|
|
// Executions list
|
|
Expanded(
|
|
child: _buildExecutionsList(colorScheme),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(ColorScheme colorScheme) {
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
Iconsax.flash_1,
|
|
color: colorScheme.primary,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Agent Executions',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
_executions != null
|
|
? '${_executions!.length} total executions'
|
|
: 'Loading...',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Iconsax.refresh, color: colorScheme.primary),
|
|
onPressed: _loadExecutions,
|
|
tooltip: 'Refresh executions',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricsCards(ColorScheme colorScheme) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildMetricCard(
|
|
'Total Cost',
|
|
'\$${_totalCost.toStringAsFixed(4)}',
|
|
Iconsax.dollar_circle,
|
|
colorScheme.primaryContainer,
|
|
colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildMetricCard(
|
|
'Total Tokens',
|
|
_totalTokens.toString(),
|
|
Iconsax.cpu,
|
|
colorScheme.secondaryContainer,
|
|
colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildMetricCard(
|
|
'Avg Messages',
|
|
_executions != null && _executions!.isNotEmpty
|
|
? (_executions!.fold<int>(
|
|
0,
|
|
(int sum, ExecutionListItemDto exec) =>
|
|
sum + exec.messageCount,
|
|
) /
|
|
_executions!.length)
|
|
.toStringAsFixed(1)
|
|
: '0',
|
|
Iconsax.message_text,
|
|
colorScheme.tertiaryContainer,
|
|
colorScheme.onTertiaryContainer,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricCard(
|
|
String label,
|
|
String value,
|
|
IconData icon,
|
|
Color backgroundColor,
|
|
Color textColor,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: textColor, size: 32),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: textColor.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: textColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusFilters(ColorScheme colorScheme) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildStatusChip('All', null, colorScheme),
|
|
const SizedBox(width: 8),
|
|
_buildStatusChip(
|
|
'Pending',
|
|
ExecutionStatus.pending,
|
|
colorScheme,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildStatusChip(
|
|
'Running',
|
|
ExecutionStatus.running,
|
|
colorScheme,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildStatusChip(
|
|
'Completed',
|
|
ExecutionStatus.completed,
|
|
colorScheme,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildStatusChip(
|
|
'Failed',
|
|
ExecutionStatus.failed,
|
|
colorScheme,
|
|
),
|
|
const SizedBox(width: 8),
|
|
_buildStatusChip(
|
|
'Cancelled',
|
|
ExecutionStatus.cancelled,
|
|
colorScheme,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusChip(
|
|
String label,
|
|
ExecutionStatus? status,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final bool isSelected = _filterStatus == status;
|
|
final int count =
|
|
status == null ? (_executions?.length ?? 0) : (_statusCounts[status.value] ?? 0);
|
|
|
|
return FilterChip(
|
|
label: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(label),
|
|
if (count > 0) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? colorScheme.onPrimaryContainer.withValues(alpha: 0.2)
|
|
: colorScheme.onSurface.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
count.toString(),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: isSelected
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
selected: isSelected,
|
|
onSelected: (bool selected) {
|
|
setState(() {
|
|
_filterStatus = status;
|
|
});
|
|
_loadExecutions();
|
|
},
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
selectedColor: colorScheme.primaryContainer,
|
|
labelStyle: TextStyle(
|
|
color: isSelected
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurface,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildExecutionsList(ColorScheme colorScheme) {
|
|
if (_isLoading) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(color: colorScheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Loading executions...',
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_errorMessage != null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Iconsax.warning_2,
|
|
size: 48,
|
|
color: colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load executions',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_errorMessage!,
|
|
style: TextStyle(color: colorScheme.error),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _loadExecutions,
|
|
icon: const Icon(Iconsax.refresh),
|
|
label: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_executions == null || _executions!.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Iconsax.flash_1,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No executions found',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_filterStatus == null
|
|
? 'Start an agent execution to see it here'
|
|
: 'No ${_filterStatus!.value.toLowerCase()} executions',
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: _executions!.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
return FadeInUp(
|
|
duration: Duration(milliseconds: 300 + (index * 50)),
|
|
child: _buildExecutionCard(_executions![index], colorScheme),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildExecutionCard(
|
|
ExecutionListItemDto execution,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
final Color statusColor = _getStatusColor(execution.status, colorScheme);
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
elevation: 0,
|
|
color: colorScheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () {
|
|
// TODO: Navigate to execution detail
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('View execution details (not implemented)'),
|
|
),
|
|
);
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header row
|
|
Row(
|
|
children: [
|
|
// Status indicator
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
color: statusColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Agent name
|
|
Expanded(
|
|
child: Text(
|
|
execution.agentName,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Status badge
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: statusColor.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
execution.status.value,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: statusColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// User prompt
|
|
Text(
|
|
execution.userPrompt,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
|
|
// Error message if failed
|
|
if (execution.errorMessage != null) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Iconsax.warning_2,
|
|
size: 16,
|
|
color: colorScheme.onErrorContainer,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
execution.errorMessage!,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onErrorContainer,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
|
|
// Stats row
|
|
Row(
|
|
children: [
|
|
_buildExecutionStat(
|
|
Iconsax.message_text,
|
|
'${execution.messageCount}',
|
|
colorScheme,
|
|
),
|
|
if (execution.inputTokens != null &&
|
|
execution.outputTokens != null) ...[
|
|
const SizedBox(width: 16),
|
|
_buildExecutionStat(
|
|
Iconsax.cpu,
|
|
'${execution.inputTokens! + execution.outputTokens!}',
|
|
colorScheme,
|
|
),
|
|
],
|
|
if (execution.estimatedCost != null) ...[
|
|
const SizedBox(width: 16),
|
|
_buildExecutionStat(
|
|
Iconsax.dollar_circle,
|
|
'\$${execution.estimatedCost!.toStringAsFixed(4)}',
|
|
colorScheme,
|
|
),
|
|
],
|
|
const Spacer(),
|
|
Icon(
|
|
Iconsax.clock,
|
|
size: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
timeago.format(execution.startedAt),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildExecutionStat(
|
|
IconData icon,
|
|
String value,
|
|
ColorScheme colorScheme,
|
|
) {
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 16,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Color _getStatusColor(ExecutionStatus status, ColorScheme colorScheme) {
|
|
switch (status) {
|
|
case ExecutionStatus.pending:
|
|
return colorScheme.tertiary;
|
|
case ExecutionStatus.running:
|
|
return colorScheme.primary;
|
|
case ExecutionStatus.completed:
|
|
return Colors.green;
|
|
case ExecutionStatus.failed:
|
|
return colorScheme.error;
|
|
case ExecutionStatus.cancelled:
|
|
return colorScheme.onSurfaceVariant;
|
|
}
|
|
}
|
|
}
|