feat: add frontend app, improve static file serving with lazy loading and root redirect

This commit is contained in:
2026-07-20 21:03:48 +03:00
parent 799e939037
commit d1e96d35aa
27 changed files with 2053 additions and 14 deletions
+107
View File
@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'models.dart';
class APIError implements Exception {
final int statusCode;
final String message;
APIError(this.statusCode, this.message);
@override
String toString() => 'APIError $statusCode: $message';
}
class APIClient {
final String baseUrl;
APIClient({this.baseUrl = 'http://localhost:8000'});
String _url(String path) => '$baseUrl$path';
static dynamic _decodeBody(http.Response response) {
return jsonDecode(response.body);
}
static Map<String, dynamic>? _asMap(dynamic d) {
if (d is Map<String, dynamic>) return d;
if (d is Map) return d.cast<String, dynamic>();
return null;
}
static void _checkError(http.Response response) {
if (response.statusCode >= 400) {
final body = _decodeBody(response);
final map = _asMap(body) ?? {};
throw APIError(
response.statusCode,
map['error'] as String? ?? 'Request failed',
);
}
}
Future<List<Pipeline>> listPipelines() async {
final res = await http.get(Uri.parse(_url('/api/pipelines')));
_checkError(res);
final data = _decodeBody(res);
final arr = data is List ? data : [];
final result = <Pipeline>[];
for (final e in arr) {
final m = _asMap(e);
if (m != null) result.add(Pipeline.fromJson(m));
}
return result;
}
Future<Pipeline> getPipeline(String id) async {
final res = await http.get(Uri.parse(_url('/api/pipelines/$id')));
_checkError(res);
final data = _decodeBody(res);
final map = _asMap(data) ?? {};
final inner = _asMap(map['data']) ?? map;
return Pipeline.fromJson(inner);
}
Future<Pipeline> createPipeline(Pipeline pipeline) async {
final res = await http.post(
Uri.parse(_url('/api/pipelines')),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(pipeline.toJson()),
);
_checkError(res);
final data = _decodeBody(res);
final map = _asMap(data) ?? {};
final inner = _asMap(map['data']) ?? map;
return Pipeline.fromJson(inner);
}
Future<void> deletePipeline(String id) async {
final res = await http.delete(Uri.parse(_url('/api/pipelines/$id')));
_checkError(res);
}
Future<Map<String, dynamic>> startRun(String pipelineId) async {
final res = await http.post(
Uri.parse(_url('/api/runs')),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'pipeline_id': pipelineId}),
);
_checkError(res);
final data = _decodeBody(res);
final map = _asMap(data) ?? {};
return _asMap(map['data']) ?? map;
}
Future<RunState> getRunStatus(String runId) async {
final res = await http.get(Uri.parse(_url('/api/runs/$runId/status')));
_checkError(res);
final data = _decodeBody(res);
final map = _asMap(data) ?? {};
final inner = _asMap(map['data']) ?? map;
return RunState.fromJson(inner);
}
Future<String> getRunLog(String runId) async {
final res = await http.get(Uri.parse(_url('/api/runs/$runId/log')));
_checkError(res);
return res.body;
}
}
+158
View File
@@ -0,0 +1,158 @@
class Prompt {
final String id;
final String text;
final String title;
final int order;
Prompt({
required this.id,
required this.text,
required this.title,
required this.order,
});
factory Prompt.fromJson(Map<String, dynamic> json) {
return Prompt(
id: json['id'] as String? ?? '',
text: json['text'] as String? ?? '',
title: json['title'] as String? ?? '',
order: json['order'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {'id': id, 'text': text, 'title': title, 'order': order};
}
Prompt copyWith({String? id, String? text, String? title, int? order}) {
return Prompt(
id: id ?? this.id,
text: text ?? this.text,
title: title ?? this.title,
order: order ?? this.order,
);
}
}
class Pipeline {
final String id;
final String name;
final List<Prompt> prompts;
final String workingDir;
final String createdAt;
final String updatedAt;
Pipeline({
required this.id,
required this.name,
required this.prompts,
required this.workingDir,
required this.createdAt,
required this.updatedAt,
});
factory Pipeline.fromJson(Map<String, dynamic> json) {
return Pipeline(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
prompts:
(json['prompts'] as List<dynamic>?)
?.map((p) => Prompt.fromJson(p as Map<String, dynamic>))
.toList() ??
[],
workingDir: json['working_dir'] as String? ?? '',
createdAt: json['created_at'] as String? ?? '',
updatedAt: json['updated_at'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'prompts': prompts.map((p) => p.toJson()).toList(),
'working_dir': workingDir,
'created_at': createdAt,
'updated_at': updatedAt,
};
}
}
enum RunStatus { pending, running, completed, error }
RunStatus parseRunStatus(String s) {
switch (s) {
case 'pending':
return RunStatus.pending;
case 'running':
return RunStatus.running;
case 'completed':
return RunStatus.completed;
case 'error':
return RunStatus.error;
default:
return RunStatus.pending;
}
}
class StepResult {
final int stepIndex;
final String title;
final RunStatus status;
final int returnCode;
final String output;
final String error;
StepResult({
required this.stepIndex,
required this.title,
required this.status,
required this.returnCode,
required this.output,
required this.error,
});
factory StepResult.fromJson(Map<String, dynamic> json) {
return StepResult(
stepIndex: json['step_index'] as int? ?? 0,
title: json['title'] as String? ?? '',
status: parseRunStatus(json['status'] as String? ?? 'pending'),
returnCode: json['returncode'] as int? ?? 0,
output: json['output'] as String? ?? '',
error: json['error'] as String? ?? '',
);
}
}
class RunState {
final String runId;
final String pipelineId;
final RunStatus status;
final int currentStep;
final List<StepResult> steps;
RunState({
required this.runId,
required this.pipelineId,
required this.status,
required this.currentStep,
required this.steps,
});
factory RunState.fromJson(Map<String, dynamic> json) {
return RunState(
runId: json['run_id'] as String? ?? '',
pipelineId: json['pipeline_id'] as String? ?? '',
status: parseRunStatus(json['status'] as String? ?? 'pending'),
currentStep: json['current_step'] as int? ?? -1,
steps:
(json['steps'] as List<dynamic>?)
?.map((s) => StepResult.fromJson(s as Map<String, dynamic>))
.toList() ??
[],
);
}
bool get isFinished =>
status == RunStatus.completed || status == RunStatus.error;
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'screens/pipeline_list.dart';
void main() {
runApp(const ZaloopipeApp());
}
class ZaloopipeApp extends StatelessWidget {
const ZaloopipeApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Zaloopipe',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.light,
),
darkTheme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
brightness: Brightness.dark,
),
themeMode: ThemeMode.system,
home: const PipelineListScreen(),
);
}
}
+124
View File
@@ -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,
),
),
),
),
);
}
}
+179
View File
@@ -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)),
],
),
);
}
}
+254
View File
@@ -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'),
),
),
),
),
],
),
),
);
}
}
+184
View File
@@ -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),
);
},
),
),
);
}
}
+247
View File
@@ -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...',
),
],
),
),
],
),
),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../api/models.dart';
class PipelineCard extends StatelessWidget {
final Pipeline pipeline;
final VoidCallback onTap;
final VoidCallback onDelete;
const PipelineCard({
super.key,
required this.pipeline,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
leading: const Icon(Icons.api, size: 32),
title: Text(
pipeline.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${pipeline.prompts.length} prompt${pipeline.prompts.length != 1 ? 's' : ''}',
),
if (pipeline.workingDir.isNotEmpty)
Text(
'📁 ${pipeline.workingDir}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: onDelete,
tooltip: 'Delete',
),
onTap: onTap,
),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../api/models.dart';
class PromptEditor extends StatefulWidget {
final Prompt prompt;
final ValueChanged<Prompt> onChanged;
final VoidCallback? onDelete;
final bool showDelete;
const PromptEditor({
super.key,
required this.prompt,
required this.onChanged,
this.onDelete,
this.showDelete = true,
});
@override
State<PromptEditor> createState() => _PromptEditorState();
}
class _PromptEditorState extends State<PromptEditor> {
late TextEditingController textController;
late TextEditingController titleController;
int order = 0;
@override
void initState() {
super.initState();
textController = TextEditingController(text: widget.prompt.text);
titleController = TextEditingController(text: widget.prompt.title);
order = widget.prompt.order;
}
@override
void dispose() {
textController.dispose();
titleController.dispose();
super.dispose();
}
void _notifyChanged() {
widget.onChanged(
widget.prompt.copyWith(
text: textController.text,
title: titleController.text,
order: order,
),
);
}
@override
Widget build(BuildContext context) {
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 ${order + 1}')),
const Spacer(),
if (widget.showDelete && widget.onDelete != null)
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.onDelete,
tooltip: 'Remove prompt',
),
],
),
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => _notifyChanged(),
),
const SizedBox(height: 8),
TextField(
controller: textController,
decoration: const InputDecoration(
labelText: 'Prompt Text',
border: OutlineInputBorder(),
),
maxLines: 4,
onChanged: (_) => _notifyChanged(),
),
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../api/models.dart';
class RunStatusBadge extends StatelessWidget {
final RunStatus status;
const RunStatusBadge({super.key, required this.status});
Color _color(BuildContext context) {
switch (status) {
case RunStatus.pending:
return Colors.amber;
case RunStatus.running:
return Colors.blue;
case RunStatus.completed:
return Colors.green;
case RunStatus.error:
return Colors.red;
}
}
IconData _icon() {
switch (status) {
case RunStatus.pending:
return Icons.schedule;
case RunStatus.running:
return Icons.play_circle_outline;
case RunStatus.completed:
return Icons.check_circle;
case RunStatus.error:
return Icons.error;
}
}
@override
Widget build(BuildContext context) {
final c = _color(context);
return Chip(
avatar: Icon(_icon(), size: 16, color: c),
label: Text(
status.name.toUpperCase(),
style: TextStyle(color: c, fontWeight: FontWeight.bold, fontSize: 12),
),
backgroundColor: c.withValues(alpha: 0.1),
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../api/models.dart';
class StepProgress extends StatelessWidget {
final List<StepResult> steps;
final int totalSteps;
const StepProgress({
super.key,
required this.steps,
required this.totalSteps,
});
@override
Widget build(BuildContext context) {
if (totalSteps == 0) return const SizedBox.shrink();
return Column(
children: steps.map((step) {
IconData icon;
Color color;
switch (step.status) {
case RunStatus.pending:
icon = Icons.circle_outlined;
color = Colors.grey;
break;
case RunStatus.running:
icon = Icons.pending;
color = Colors.blue;
break;
case RunStatus.completed:
icon = Icons.check_circle;
color = Colors.green;
break;
case RunStatus.error:
icon = Icons.error;
color = Colors.red;
break;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
step.title.isNotEmpty
? step.title
: 'Step ${step.stepIndex + 1}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: step.status == RunStatus.running ? color : null,
),
),
if (step.status == RunStatus.error && step.error.isNotEmpty)
Text(
step.error,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
],
),
),
Text(
step.status.name.toUpperCase(),
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}).toList(),
);
}
}