feat: add frontend app, improve static file serving with lazy loading and root redirect
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "ee80f08bbf97172ec030b8751ceab557177a34a6"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
- platform: web
|
||||
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,17 @@
|
||||
# frontend
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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...',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
@@ -0,0 +1,21 @@
|
||||
name: zaloopipe_frontend
|
||||
description: "Zaloopipe pipeline runner web UI."
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
http: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Zaloopipe pipeline runner web UI.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="frontend">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>Zaloopipe</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"short_name": "frontend",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
"port": 8000,
|
||||
"pipelines_dir": "./storage/pipelines/",
|
||||
"logs_dir": "./storage/logs/",
|
||||
"web_root": ""
|
||||
"web_root": "../../frontend/build/web/"
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ bool Server::start() {
|
||||
|
||||
void Server::serveStaticFiles(const PIString & webRoot) {
|
||||
PIDir rootDir(webRoot);
|
||||
if (!rootDir.isExists()) {
|
||||
piCout << "WARNING: web_root does not exist: " << webRoot;
|
||||
return;
|
||||
}
|
||||
|
||||
piCout << "Serving static files from: " << rootDir.absolutePath();
|
||||
|
||||
const auto filelist = PIDir::allEntries(webRoot);
|
||||
@@ -88,23 +93,22 @@ void Server::serveStaticFiles(const PIString & webRoot) {
|
||||
if (!finfo.isFile()) continue;
|
||||
if (finfo.name().startsWith('.')) continue;
|
||||
|
||||
PIString relPath = finfo.dir().removeAll(webRoot) + finfo.name();
|
||||
if (relPath.isEmpty() || relPath[0] != '/') {
|
||||
relPath = "/" + relPath;
|
||||
// Compute relative path, same logic as meshbackend
|
||||
PIString wpath = finfo.dir().removeAll(rootDir.absolutePath()) + finfo.name();
|
||||
if (wpath.isEmpty() || wpath[0] != '/') {
|
||||
wpath = "/" + wpath;
|
||||
}
|
||||
|
||||
PIString ext = finfo.extension().toLowerCase();
|
||||
PIString contentType = StaticContentTypes.value(ext, "application/octet-stream");
|
||||
|
||||
PIByteArray fileData = PIFile::readAll(finfo.path);
|
||||
PIString contentType = StaticContentTypes.value(finfo.extension().toLowerCase(), "application/octet-stream");
|
||||
PIByteArray fileData = PIFile::readAll(rootDir.absolutePath() + wpath);
|
||||
PIString etag = PIDigest::calculate(fileData, PIDigest::Type::BLAKE2s_128).toHex().quote();
|
||||
|
||||
piCout << " Registered: " << relPath << " (" << contentType << ", ETag: " << etag << ")";
|
||||
piCout << " Registered: " << wpath << " (" << contentType << ", " << fileData.size() << " bytes)";
|
||||
|
||||
auto handler = [fileData, contentType, etag](const PIHTTP::MessageConst & request) {
|
||||
auto handler = [wpath, rootLocation = rootDir.absolutePath(), contentType, etag](const PIHTTP::MessageConst & request) {
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.addHeader(PIHTTP::Header::ContentType, contentType);
|
||||
msg.addHeader(PIHTTP::Header::CacheControl, "max-age=3600");
|
||||
msg.addHeader(PIHTTP::Header::CacheControl, "max-age=100");
|
||||
msg.addHeader(PIHTTP::Header::ETag, etag);
|
||||
|
||||
PIString clientEtag = request.headers().value(PIHTTP::Header::IfNoneMatch, PIString());
|
||||
@@ -113,12 +117,20 @@ void Server::serveStaticFiles(const PIString & webRoot) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
msg.setBody(fileData);
|
||||
msg.setBody(PIFile::readAll(rootLocation + wpath));
|
||||
return msg;
|
||||
};
|
||||
|
||||
httpserver_->registerPath(relPath, PIHTTP::Method::Get, handler);
|
||||
httpserver_->registerPath(wpath, PIHTTP::Method::Get, handler);
|
||||
}
|
||||
|
||||
// Redirect "/" to "/index.html" (same as meshbackend)
|
||||
httpserver_->registerPath("/", PIHTTP::Method::Get, [](const PIHTTP::MessageConst &) {
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.setCode(PIHTTP::Code::MovedPermanently);
|
||||
msg.addHeader("Location", "/index.html");
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
PIHTTP::MessageMutable Server::listPipelines(const PIHTTP::MessageConst & request) {
|
||||
@@ -327,5 +339,7 @@ PIHTTP::MessageMutable Server::getRunLog(const PIHTTP::MessageConst & request) {
|
||||
|
||||
PIHTTP::MessageMutable Server::unhandledRequest(const PIHTTP::MessageConst & request) {
|
||||
piCout << "Unhandled: " << PIHTTP::methodName(request.method()) << " " << request.path();
|
||||
return MessageUtils::errorReply(PIHTTP::Code::NotFound, "Not found");
|
||||
PIHTTP::MessageMutable msg;
|
||||
msg.setCode(PIHTTP::Code::NotFound);
|
||||
return msg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user