feat: add frontend app, improve static file serving with lazy loading and root redirect
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class LogViewerScreen extends StatefulWidget {
|
||||
final String runId;
|
||||
final dynamic api;
|
||||
final ValueChanged<bool>? onStatusChanged;
|
||||
|
||||
const LogViewerScreen({
|
||||
super.key,
|
||||
required this.runId,
|
||||
required this.api,
|
||||
this.onStatusChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LogViewerScreen> createState() => _LogViewerScreenState();
|
||||
}
|
||||
|
||||
class _LogViewerScreenState extends State<LogViewerScreen> {
|
||||
String _log = '';
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
Timer? _pollTimer;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _autoScroll = true;
|
||||
|
||||
Future<void> _loadLog() async {
|
||||
try {
|
||||
final log = await widget.api.getRunLog(widget.runId);
|
||||
final changed = log != _log;
|
||||
setState(() {
|
||||
_log = log;
|
||||
_loading = false;
|
||||
});
|
||||
if (changed && _autoScroll && mounted) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLog();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _loadLog());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _copyLog() async {
|
||||
await Clipboard.setData(ClipboardData(text: _log));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Log copied to clipboard')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Run Log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLog,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: _log.isNotEmpty ? _copyLog : null,
|
||||
tooltip: 'Copy',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_autoScroll ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up,
|
||||
),
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _log.isEmpty
|
||||
? const Center(
|
||||
child: Text('Log is empty', style: TextStyle(color: Colors.grey)),
|
||||
)
|
||||
: SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_log,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../api/client.dart';
|
||||
import '../api/models.dart';
|
||||
import 'run_screen.dart';
|
||||
import 'pipeline_editor.dart';
|
||||
|
||||
class PipelineDetailScreen extends StatefulWidget {
|
||||
final Pipeline pipeline;
|
||||
|
||||
const PipelineDetailScreen({super.key, required this.pipeline});
|
||||
|
||||
@override
|
||||
State<PipelineDetailScreen> createState() => _PipelineDetailScreenState();
|
||||
}
|
||||
|
||||
class _PipelineDetailScreenState extends State<PipelineDetailScreen> {
|
||||
late APIClient _api;
|
||||
late Pipeline _pipeline;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pipeline = widget.pipeline;
|
||||
_api = APIClient(baseUrl: _parseBaseUrl());
|
||||
_load();
|
||||
}
|
||||
|
||||
static String _parseBaseUrl() {
|
||||
final uri = Uri.base;
|
||||
return '${uri.scheme}://${uri.host}${uri.port != 80 && uri.port != 443 ? ':${uri.port}' : ''}';
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final p = await _api.getPipeline(_pipeline.id);
|
||||
setState(() {
|
||||
_pipeline = p;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _loading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runPipeline() async {
|
||||
await Navigator.push<void>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RunScreen(pipeline: _pipeline, api: _api),
|
||||
),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _editPipeline() async {
|
||||
final result = await Navigator.push<Pipeline>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PipelineEditorScreen(pipeline: _pipeline),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _pipeline = result);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_pipeline.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: _editPipeline,
|
||||
tooltip: 'Edit',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _runPipeline,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Run Pipeline'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_infoRow('ID', _pipeline.id),
|
||||
_infoRow(
|
||||
'Working Dir',
|
||||
_pipeline.workingDir.isNotEmpty
|
||||
? _pipeline.workingDir
|
||||
: '(not set)',
|
||||
),
|
||||
_infoRow('Created', _pipeline.createdAt),
|
||||
_infoRow('Updated', _pipeline.updatedAt),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Prompts',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._pipeline.prompts.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final p = entry.value;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Chip(label: Text('Step ${i + 1}')),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
p.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(p.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../api/models.dart';
|
||||
import '../widgets/prompt_editor.dart';
|
||||
import '../api/client.dart';
|
||||
|
||||
class PipelineEditorScreen extends StatefulWidget {
|
||||
final Pipeline? pipeline;
|
||||
|
||||
const PipelineEditorScreen({super.key, this.pipeline});
|
||||
|
||||
@override
|
||||
State<PipelineEditorScreen> createState() => _PipelineEditorScreenState();
|
||||
}
|
||||
|
||||
class _PipelineEditorScreenState extends State<PipelineEditorScreen> {
|
||||
late TextEditingController _idController;
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _dirController;
|
||||
late List<Prompt> _prompts;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
late APIClient _api;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_api = APIClient(baseUrl: _parseBaseUrl());
|
||||
|
||||
if (widget.pipeline != null) {
|
||||
_idController = TextEditingController(text: widget.pipeline!.id);
|
||||
_nameController = TextEditingController(text: widget.pipeline!.name);
|
||||
_dirController = TextEditingController(text: widget.pipeline!.workingDir);
|
||||
_prompts = List.from(widget.pipeline!.prompts);
|
||||
} else {
|
||||
_idController = TextEditingController();
|
||||
_nameController = TextEditingController();
|
||||
_dirController = TextEditingController();
|
||||
_prompts = [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_idController.dispose();
|
||||
_nameController.dispose();
|
||||
_dirController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static String _parseBaseUrl() {
|
||||
final uri = Uri.base;
|
||||
return '${uri.scheme}://${uri.host}${uri.port != 80 && uri.port != 443 ? ':${uri.port}' : ''}';
|
||||
}
|
||||
|
||||
void _addPrompt() {
|
||||
setState(() {
|
||||
_prompts.add(
|
||||
Prompt(id: _generateId(), text: '', title: '', order: _prompts.length),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _removePrompt(int index) {
|
||||
setState(() {
|
||||
_prompts.removeAt(index);
|
||||
for (int i = index; i < _prompts.length; i++) {
|
||||
_prompts[i] = _prompts[i].copyWith(order: i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updatePrompt(int index, Prompt updated) {
|
||||
setState(() {
|
||||
_prompts[index] = updated;
|
||||
});
|
||||
}
|
||||
|
||||
static String _generateId() {
|
||||
return DateTime.now().millisecondsSinceEpoch.toRadixString(36);
|
||||
}
|
||||
|
||||
bool _validate() {
|
||||
if (_idController.text.trim().isEmpty) {
|
||||
setState(() => _error = 'ID is required');
|
||||
return false;
|
||||
}
|
||||
if (_nameController.text.trim().isEmpty) {
|
||||
setState(() => _error = 'Name is required');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_validate()) return;
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final sortedPrompts = List<Prompt>.from(_prompts)
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
final pipeline = Pipeline(
|
||||
id: _idController.text.trim(),
|
||||
name: _nameController.text.trim(),
|
||||
prompts: sortedPrompts,
|
||||
workingDir: _dirController.text.trim(),
|
||||
createdAt: widget.pipeline?.createdAt ?? '',
|
||||
updatedAt: '',
|
||||
);
|
||||
|
||||
try {
|
||||
final saved = await _api.createPipeline(pipeline);
|
||||
if (mounted) {
|
||||
Navigator.pop(context, saved);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isEdit = widget.pipeline != null;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isEdit ? 'Edit Pipeline' : 'Create Pipeline'),
|
||||
actions: [
|
||||
if (isEdit)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check, color: Colors.green),
|
||||
onPressed: _saving ? null : _save,
|
||||
tooltip: 'Save',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Card(
|
||||
color: Colors.red.shade50,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(_error!)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _idController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ID',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
enabled: !isEdit,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _dirController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Working Directory',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Prompts',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _addPrompt,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add Step'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_prompts.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No prompts yet. Add a step to get started.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < _prompts.length; i++)
|
||||
PromptEditor(
|
||||
prompt: _prompts[i],
|
||||
showDelete: _prompts.length > 1,
|
||||
onChanged: (p) => _updatePrompt(i, p),
|
||||
onDelete: () => _removePrompt(i),
|
||||
),
|
||||
if (_prompts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save),
|
||||
label: Text(
|
||||
_saving
|
||||
? 'Saving...'
|
||||
: (isEdit ? 'Save Changes' : 'Create Pipeline'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../api/client.dart';
|
||||
import '../api/models.dart';
|
||||
import '../widgets/pipeline_card.dart';
|
||||
import 'pipeline_editor.dart';
|
||||
import 'pipeline_detail.dart';
|
||||
|
||||
class PipelineListScreen extends StatefulWidget {
|
||||
const PipelineListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PipelineListScreen> createState() => _PipelineListScreenState();
|
||||
}
|
||||
|
||||
class _PipelineListScreenState extends State<PipelineListScreen> {
|
||||
late APIClient _api;
|
||||
List<Pipeline> _pipelines = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final url = _parseBaseUrl();
|
||||
_api = APIClient(baseUrl: url);
|
||||
_load();
|
||||
}
|
||||
|
||||
static String _parseBaseUrl() {
|
||||
final uri = Uri.base;
|
||||
return '${uri.scheme}://${uri.host}${uri.port != 80 && uri.port != 443 ? ':${uri.port}' : ''}';
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final pipelines = await _api.listPipelines();
|
||||
setState(() {
|
||||
_pipelines = pipelines;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createPipeline() async {
|
||||
final result = await Navigator.push<Pipeline>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const PipelineEditorScreen()),
|
||||
);
|
||||
if (result != null) {
|
||||
_pipelines.add(result);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToDetail(Pipeline p) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => PipelineDetailScreen(pipeline: p)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deletePipeline(Pipeline p) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Pipeline'),
|
||||
content: Text('Are you sure you want to delete "${p.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _api.deletePipeline(p.id);
|
||||
setState(() {
|
||||
_pipelines.removeWhere((pl) => pl.id == p.id);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Zaloopipe'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _pipelines.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.api_outlined, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No pipelines yet',
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _createPipeline,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create Pipeline'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: _pipelines.length + 1,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == _pipelines.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _createPipeline,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create Pipeline'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final p = _pipelines[i];
|
||||
return PipelineCard(
|
||||
pipeline: p,
|
||||
onTap: () => _navigateToDetail(p),
|
||||
onDelete: () => _deletePipeline(p),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../api/client.dart';
|
||||
import '../api/models.dart';
|
||||
import '../widgets/run_status_badge.dart';
|
||||
import '../widgets/step_progress.dart';
|
||||
import 'log_viewer.dart';
|
||||
|
||||
class RunScreen extends StatefulWidget {
|
||||
final Pipeline pipeline;
|
||||
final APIClient api;
|
||||
|
||||
const RunScreen({super.key, required this.pipeline, required this.api});
|
||||
|
||||
@override
|
||||
State<RunScreen> createState() => _RunScreenState();
|
||||
}
|
||||
|
||||
class _RunScreenState extends State<RunScreen> {
|
||||
String? _runId;
|
||||
RunState? _runState;
|
||||
String? _error;
|
||||
bool _starting = true;
|
||||
Timer? _pollTimer;
|
||||
|
||||
Future<void> _start() async {
|
||||
try {
|
||||
final data = await widget.api.startRun(widget.pipeline.id);
|
||||
setState(() {
|
||||
_runId = data['run_id'] as String;
|
||||
_runState = RunState(
|
||||
runId: data['run_id'] as String,
|
||||
pipelineId: data['pipeline_id'] as String? ?? '',
|
||||
status: parseRunStatus(data['status'] as String? ?? 'running'),
|
||||
currentStep: -1,
|
||||
steps: [],
|
||||
);
|
||||
_starting = false;
|
||||
});
|
||||
_startPolling();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_starting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (_runId == null) return;
|
||||
try {
|
||||
final state = await widget.api.getRunStatus(_runId!);
|
||||
setState(() {
|
||||
_runState = state;
|
||||
});
|
||||
if (state.isFinished) {
|
||||
_pollTimer?.cancel();
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start();
|
||||
}
|
||||
|
||||
void _viewLog() {
|
||||
if (_runId == null) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LogViewerScreen(
|
||||
runId: _runId!,
|
||||
api: widget.api,
|
||||
onStatusChanged: (finished) {
|
||||
if (finished) _pollTimer?.cancel();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_starting) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Run: ${widget.pipeline.name}')),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Starting run...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Run: ${widget.pipeline.name}')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Go Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_runId == null || _runState == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Run: ${widget.pipeline.name}')),
|
||||
body: const Center(child: Text('Failed to start run')),
|
||||
);
|
||||
}
|
||||
|
||||
final state = _runState!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Run: ${widget.pipeline.name}'),
|
||||
actions: [RunStatusBadge(status: state.status)],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Run ID',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_runId!,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Status: ',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
RunStatusBadge(status: state.status),
|
||||
const SizedBox(width: 12),
|
||||
if (state.currentStep >= 0)
|
||||
Text(
|
||||
'Step ${state.currentStep + 1}/${state.steps.length}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Steps',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
StepProgress(steps: state.steps, totalSteps: state.steps.length),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _viewLog,
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: const Text('View Log'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.isFinished) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Done'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!state.isFinished)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
state.status == RunStatus.running
|
||||
? 'Running... (polling every 2s)'
|
||||
: 'Waiting...',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user