diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/frontend/.gitignore @@ -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 diff --git a/frontend/.metadata b/frontend/.metadata new file mode 100644 index 0000000..c84df4c --- /dev/null +++ b/frontend/.metadata @@ -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' diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..92d1efb --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/analysis_options.yaml b/frontend/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/frontend/analysis_options.yaml @@ -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 diff --git a/frontend/lib/api/client.dart b/frontend/lib/api/client.dart new file mode 100644 index 0000000..8211a85 --- /dev/null +++ b/frontend/lib/api/client.dart @@ -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? _asMap(dynamic d) { + if (d is Map) return d; + if (d is Map) return d.cast(); + 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> 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 = []; + for (final e in arr) { + final m = _asMap(e); + if (m != null) result.add(Pipeline.fromJson(m)); + } + return result; + } + + Future 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 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 deletePipeline(String id) async { + final res = await http.delete(Uri.parse(_url('/api/pipelines/$id'))); + _checkError(res); + } + + Future> 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 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 getRunLog(String runId) async { + final res = await http.get(Uri.parse(_url('/api/runs/$runId/log'))); + _checkError(res); + return res.body; + } +} diff --git a/frontend/lib/api/models.dart b/frontend/lib/api/models.dart new file mode 100644 index 0000000..94ed392 --- /dev/null +++ b/frontend/lib/api/models.dart @@ -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 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 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 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 json) { + return Pipeline( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + prompts: + (json['prompts'] as List?) + ?.map((p) => Prompt.fromJson(p as Map)) + .toList() ?? + [], + workingDir: json['working_dir'] as String? ?? '', + createdAt: json['created_at'] as String? ?? '', + updatedAt: json['updated_at'] as String? ?? '', + ); + } + + Map 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 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 steps; + + RunState({ + required this.runId, + required this.pipelineId, + required this.status, + required this.currentStep, + required this.steps, + }); + + factory RunState.fromJson(Map 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?) + ?.map((s) => StepResult.fromJson(s as Map)) + .toList() ?? + [], + ); + } + + bool get isFinished => + status == RunStatus.completed || status == RunStatus.error; +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart new file mode 100644 index 0000000..14a7c0f --- /dev/null +++ b/frontend/lib/main.dart @@ -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(), + ); + } +} diff --git a/frontend/lib/screens/log_viewer.dart b/frontend/lib/screens/log_viewer.dart new file mode 100644 index 0000000..6c83a96 --- /dev/null +++ b/frontend/lib/screens/log_viewer.dart @@ -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? onStatusChanged; + + const LogViewerScreen({ + super.key, + required this.runId, + required this.api, + this.onStatusChanged, + }); + + @override + State createState() => _LogViewerScreenState(); +} + +class _LogViewerScreenState extends State { + String _log = ''; + bool _loading = true; + String? _error; + Timer? _pollTimer; + final ScrollController _scrollController = ScrollController(); + bool _autoScroll = true; + + Future _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 _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, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/pipeline_detail.dart b/frontend/lib/screens/pipeline_detail.dart new file mode 100644 index 0000000..e6a3de6 --- /dev/null +++ b/frontend/lib/screens/pipeline_detail.dart @@ -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 createState() => _PipelineDetailScreenState(); +} + +class _PipelineDetailScreenState extends State { + 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 _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 _runPipeline() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RunScreen(pipeline: _pipeline, api: _api), + ), + ); + _load(); + } + + Future _editPipeline() async { + final result = await Navigator.push( + 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)), + ], + ), + ); + } +} diff --git a/frontend/lib/screens/pipeline_editor.dart b/frontend/lib/screens/pipeline_editor.dart new file mode 100644 index 0000000..3e0ffda --- /dev/null +++ b/frontend/lib/screens/pipeline_editor.dart @@ -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 createState() => _PipelineEditorScreenState(); +} + +class _PipelineEditorScreenState extends State { + late TextEditingController _idController; + late TextEditingController _nameController; + late TextEditingController _dirController; + late List _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 _save() async { + if (!_validate()) return; + + setState(() { + _saving = true; + _error = null; + }); + + final sortedPrompts = List.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'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/screens/pipeline_list.dart b/frontend/lib/screens/pipeline_list.dart new file mode 100644 index 0000000..0df0876 --- /dev/null +++ b/frontend/lib/screens/pipeline_list.dart @@ -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 createState() => _PipelineListScreenState(); +} + +class _PipelineListScreenState extends State { + late APIClient _api; + List _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 _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 _createPipeline() async { + final result = await Navigator.push( + 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 _deletePipeline(Pipeline p) async { + final confirmed = await showDialog( + 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), + ); + }, + ), + ), + ); + } +} diff --git a/frontend/lib/screens/run_screen.dart b/frontend/lib/screens/run_screen.dart new file mode 100644 index 0000000..2de651e --- /dev/null +++ b/frontend/lib/screens/run_screen.dart @@ -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 createState() => _RunScreenState(); +} + +class _RunScreenState extends State { + String? _runId; + RunState? _runState; + String? _error; + bool _starting = true; + Timer? _pollTimer; + + Future _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 _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...', + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/pipeline_card.dart b/frontend/lib/widgets/pipeline_card.dart new file mode 100644 index 0000000..6b25421 --- /dev/null +++ b/frontend/lib/widgets/pipeline_card.dart @@ -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, + ), + ); + } +} diff --git a/frontend/lib/widgets/prompt_editor.dart b/frontend/lib/widgets/prompt_editor.dart new file mode 100644 index 0000000..b4355ec --- /dev/null +++ b/frontend/lib/widgets/prompt_editor.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import '../api/models.dart'; + +class PromptEditor extends StatefulWidget { + final Prompt prompt; + final ValueChanged onChanged; + final VoidCallback? onDelete; + final bool showDelete; + + const PromptEditor({ + super.key, + required this.prompt, + required this.onChanged, + this.onDelete, + this.showDelete = true, + }); + + @override + State createState() => _PromptEditorState(); +} + +class _PromptEditorState extends State { + 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(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/run_status_badge.dart b/frontend/lib/widgets/run_status_badge.dart new file mode 100644 index 0000000..f0b4e5f --- /dev/null +++ b/frontend/lib/widgets/run_status_badge.dart @@ -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), + ); + } +} diff --git a/frontend/lib/widgets/step_progress.dart b/frontend/lib/widgets/step_progress.dart new file mode 100644 index 0000000..c18ce87 --- /dev/null +++ b/frontend/lib/widgets/step_progress.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../api/models.dart'; + +class StepProgress extends StatelessWidget { + final List 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(), + ); + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock new file mode 100644 index 0000000..0018ada --- /dev/null +++ b/frontend/pubspec.lock @@ -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" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml new file mode 100644 index 0000000..46b5fab --- /dev/null +++ b/frontend/pubspec.yaml @@ -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 diff --git a/frontend/web/favicon.png b/frontend/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/frontend/web/favicon.png differ diff --git a/frontend/web/icons/Icon-192.png b/frontend/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/frontend/web/icons/Icon-192.png differ diff --git a/frontend/web/icons/Icon-512.png b/frontend/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/frontend/web/icons/Icon-512.png differ diff --git a/frontend/web/icons/Icon-maskable-192.png b/frontend/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-192.png differ diff --git a/frontend/web/icons/Icon-maskable-512.png b/frontend/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/frontend/web/icons/Icon-maskable-512.png differ diff --git a/frontend/web/index.html b/frontend/web/index.html new file mode 100644 index 0000000..30502bb --- /dev/null +++ b/frontend/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + Zaloopipe + + + + + + + diff --git a/frontend/web/manifest.json b/frontend/web/manifest.json new file mode 100644 index 0000000..409f4c9 --- /dev/null +++ b/frontend/web/manifest.json @@ -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" + } + ] +} diff --git a/pipeline-runner/pipeline-runner.conf b/pipeline-runner/pipeline-runner.conf index d4acb69..aef59b9 100644 --- a/pipeline-runner/pipeline-runner.conf +++ b/pipeline-runner/pipeline-runner.conf @@ -2,5 +2,5 @@ "port": 8000, "pipelines_dir": "./storage/pipelines/", "logs_dir": "./storage/logs/", - "web_root": "" + "web_root": "../../frontend/build/web/" } diff --git a/pipeline-runner/src/server.cpp b/pipeline-runner/src/server.cpp index 7aacea2..6847fef 100644 --- a/pipeline-runner/src/server.cpp +++ b/pipeline-runner/src/server.cpp @@ -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; }