From e7b96f768c52a61f64405522b682fcd06a9a4c72 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Fri, 21 Nov 2025 17:37:31 -0500 Subject: [PATCH 01/12] Add deploy to backend --- backend/main.py | 149 +++++++++++++++++++++++++++++++++++++++++++----- vite.config.mts | 2 +- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index 0c8a6f58..88bf5bab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,10 +1,15 @@ -from flask import Flask, request, jsonify +from flask import Flask, request, jsonify, send_from_directory from flask_restful import Resource, Api from flask_sqlalchemy import SQLAlchemy import json import os +import argparse +import zipfile +import shutil +from werkzeug.utils import secure_filename -app = Flask(__name__) +app = Flask(__name__, static_folder='../dist', static_url_path='') +app.url_map.merge_slashes = False # Don't merge consecutive slashes api = Api(app) # Add CORS headers @@ -224,25 +229,143 @@ def get(self): return {'files': sorted(list(children))} +class DeployResource(Resource): + def post(self): + """Upload and extract a zip file to the deploy directory""" + if 'file' not in request.files: + return {'error': 'No file provided'}, 400 + + file = request.files['file'] + + if file.filename == '': + return {'error': 'No file selected'}, 400 + + if not file.filename.endswith('.zip'): + return {'error': 'Only zip files are allowed'}, 400 + + try: + # Create deploy directory if it doesn't exist + deploy_dir = os.path.join(basedir, 'deploy') + + # Clear existing deploy directory + if os.path.exists(deploy_dir): + shutil.rmtree(deploy_dir) + os.makedirs(deploy_dir) + + # Save the zip file temporarily + temp_zip_path = os.path.join(basedir, 'temp_deploy.zip') + file.save(temp_zip_path) + + # Extract the zip file + with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: + zip_ref.extractall(deploy_dir) + + # Remove the temporary zip file + os.remove(temp_zip_path) + + # List extracted files + extracted_files = [] + for root, dirs, files in os.walk(deploy_dir): + for filename in files: + rel_path = os.path.relpath(os.path.join(root, filename), deploy_dir) + extracted_files.append(rel_path) + + return { + 'message': 'Deployment successful', + 'deploy_directory': deploy_dir, + 'files_extracted': len(extracted_files), + 'files': extracted_files[:20] # Show first 20 files + } + except zipfile.BadZipFile: + return {'error': 'Invalid zip file'}, 400 + except Exception as e: + return {'error': f'Deployment failed: {str(e)}'}, 500 + # Register API routes # Storage API routes (more specific routes first) api.add_resource(StorageEntryResource, '/entries/') api.add_resource(StorageFileRenameResource, '/storage/rename') api.add_resource(StorageRootResource, '/storage/') api.add_resource(StorageResource, '/storage/') +api.add_resource(DeployResource, '/deploy') -@app.route('/') -def index(): - return jsonify({ - 'message': 'Storage API', - 'endpoints': { - 'entries': '/entries/', - 'storage': '/storage/', - 'storage_rename': '/storage/rename' - } - }) +# Handle the base path for the frontend +@app.route('/blocks/', defaults={'path': ''}) +@app.route('/blocks/') +@app.route('/blocks//') # Handle double slash +def serve_frontend(path): + """Serve static assets from dist/ directory with base path""" + # Normalize path - remove leading slashes and clean up double slashes + path = path.lstrip('/') + + # Debug logging + print(f"Requested path: '{path}'") + + # If path is empty, serve index.html + if path == '': + try: + return send_from_directory(app.static_folder, 'index.html') + except Exception as e: + print(f"Error serving index.html: {e}") + return jsonify({ + 'error': 'Frontend not built', + 'message': 'Please build the frontend first with "npm run build"' + }), 404 + + # Try to serve the requested file + try: + print(f"Attempting to serve: {app.static_folder}/{path}") + return send_from_directory(app.static_folder, path) + except Exception as e: + print(f"Error serving file: {e}") + # If file not found and not an asset, serve index.html for client-side routing + # But if it's an asset or known file type, return 404 + if path.startswith('assets/') or '.' in path.split('/')[-1]: + return jsonify({'error': f'File not found: {path}'}), 404 + try: + return send_from_directory(app.static_folder, 'index.html') + except: + return jsonify({'error': 'File not found'}), 404 + +@app.route('/', defaults={'path': ''}) +@app.route('/') +def serve_static(path): + """Serve static assets from dist/ directory""" + # If path is empty, serve index.html + if path == '': + try: + return send_from_directory(app.static_folder, 'index.html') + except Exception as e: + return jsonify({ + 'error': 'Frontend not built', + 'message': 'Please build the frontend first with "npm run build"', + 'api_info': { + 'endpoints': { + 'entries': '/entries/', + 'storage': '/storage/', + 'storage_rename': '/storage/rename' + } + } + }), 404 + + # Try to serve the requested file + try: + return send_from_directory(app.static_folder, path) + except Exception as e: + # If file not found, serve index.html for client-side routing + try: + return send_from_directory(app.static_folder, 'index.html') + except: + return jsonify({'error': 'File not found'}), 404 if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Run the Storage API backend server') + parser.add_argument('-p', '--port', type=int, default=5001, + help='Port to run the server on (default: 5001)') + args = parser.parse_args() + with app.app_context(): db.create_all() - app.run(debug=True, port=5001) \ No newline at end of file + + print(f"Starting server on port {args.port}...") + app.run(debug=True, port=args.port) \ No newline at end of file diff --git a/vite.config.mts b/vite.config.mts index 0b24e0f0..a8ff9336 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -7,7 +7,7 @@ import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - base: '/systemcore-blocks-interface/', + base: '/blocks/', plugins: [react(), tsconfigPaths(), viteStaticCopy({ targets: [ { From e152a0da7bf3e8f48d8bd80c695900ff586e5112 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Fri, 21 Nov 2025 17:38:37 -0500 Subject: [PATCH 02/12] ignore /backend/deploy --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index b5839894..3d901a69 100644 --- a/.gitignore +++ b/.gitignore @@ -11,12 +11,14 @@ # production /build /dist +/backend/deploy # python stuff __pycache__/ venv/ *.egg-info + # misc .DS_Store .env.local From b557fb9d1e809cf43a41bdae401c7f93b9376a2a Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Sat, 22 Nov 2025 08:35:35 -0500 Subject: [PATCH 03/12] Made it so that deploy is different on server and local --- backend/main.py | 10 ++++++ src/reactComponents/Menu.tsx | 49 ++++++++++++++++++++++++------ src/storage/server_side_storage.ts | 15 ++++++--- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index 88bf5bab..0b9eaef6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -289,6 +289,16 @@ def post(self): api.add_resource(StorageResource, '/storage/') api.add_resource(DeployResource, '/deploy') +# API health check endpoint to distinguish from static file serving +@app.route('/api/status') +def api_status(): + """Health check endpoint to identify backend server""" + return jsonify({ + 'status': 'ok', + 'server': 'python-backend', + 'version': '1.0' + }) + # Handle the base path for the frontend @app.route('/blocks/', defaults={'path': ''}) @app.route('/blocks/') diff --git a/src/reactComponents/Menu.tsx b/src/reactComponents/Menu.tsx index 4c73bcaf..f48e86e3 100644 --- a/src/reactComponents/Menu.tsx +++ b/src/reactComponents/Menu.tsx @@ -25,6 +25,7 @@ import * as commonStorage from '../storage/common_storage'; import * as storageNames from '../storage/names'; import * as storageProject from '../storage/project'; import * as createPythonFiles from '../storage/create_python_files'; +import * as serverSideStorage from '../storage/server_side_storage'; import * as I18Next from 'react-i18next'; import {TabType } from '../types/TabType'; @@ -294,17 +295,45 @@ export function Component(props: MenuProps): React.JSX.Element { try { const blobUrl = await createPythonFiles.producePythonProjectBlob(props.project, props.storage); - - // Create a temporary link to download the file - const link = document.createElement('a'); - link.href = blobUrl; - link.download = `${props.project.projectName}.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - // Clean up the blob URL - URL.revokeObjectURL(blobUrl); + // Check if the backend server is available + const serverAvailable = await serverSideStorage.isServerAvailable(); + console.log('Server available:', serverAvailable); + + if (serverAvailable) { + // Send the file to the backend /deploy endpoint + const response = await fetch(blobUrl); + const blob = await response.blob(); + + const formData = new FormData(); + formData.append('file', blob, `${props.project.projectName}.zip`); + + const deployResponse = await fetch('/deploy', { + method: 'POST', + body: formData, + }); + + if (!deployResponse.ok) { + throw new Error('Deploy to server failed'); + } + + const result = await deployResponse.json(); + console.log('Deployment successful:', result); + + // Clean up the blob URL + URL.revokeObjectURL(blobUrl); + } else { + // Download the file locally + const link = document.createElement('a'); + link.href = blobUrl; + link.download = `${props.project.projectName}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the blob URL + URL.revokeObjectURL(blobUrl); + } } catch (error) { console.error('Failed to deploy project:', error); props.setAlertErrorMessage(t('DEPLOY_FAILED') || 'Failed to deploy project'); diff --git a/src/storage/server_side_storage.ts b/src/storage/server_side_storage.ts index 379cc1fb..e13ceca3 100644 --- a/src/storage/server_side_storage.ts +++ b/src/storage/server_side_storage.ts @@ -22,7 +22,7 @@ import * as commonStorage from './common_storage'; -const API_BASE_URL = 'http://localhost:5001'; +const API_BASE_URL = ''; export async function isServerAvailable(): Promise { try { @@ -31,13 +31,20 @@ export async function isServerAvailable(): Promise { setTimeout(() => reject(new Error('Timeout')), 5000); // 5 second timeout }); - // Race between the fetch and timeout + // Check the specific API status endpoint to distinguish backend from static file server + // Use absolute path without base URL since /api/status is a backend endpoint const response = await Promise.race([ - fetch(`${API_BASE_URL}/`), + fetch(`${API_BASE_URL}/api/status`), timeoutPromise ]); - return response.ok; + if (!response.ok) { + return false; + } + + // Verify it's actually the Python backend by checking the response + const data = await response.json(); + return data.server === 'python-backend'; } catch (error) { // Network error, server not available, or timeout return false; From 3ec4869d673c1ab0f9984d0b122b6645cadf440d Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 15:48:58 -0500 Subject: [PATCH 04/12] Change order of imports to be correct --- backend/main.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index 0b9eaef6..295f7aff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,14 @@ -from flask import Flask, request, jsonify, send_from_directory -from flask_restful import Resource, Api -from flask_sqlalchemy import SQLAlchemy +# Standard library imports +import argparse import json import os -import argparse -import zipfile import shutil +import zipfile + +# Third-party imports +from flask import Flask, jsonify, request, send_from_directory +from flask_restful import Api, Resource +from flask_sqlalchemy import SQLAlchemy from werkzeug.utils import secure_filename app = Flask(__name__, static_folder='../dist', static_url_path='') From db43fee87b4d2881f4bbc64afbcbfecd4a8dde6d Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 15:56:45 -0500 Subject: [PATCH 05/12] break out deploy --- backend/deploy.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++ backend/main.py | 57 +++--------------------------------------- 2 files changed, 66 insertions(+), 54 deletions(-) create mode 100644 backend/deploy.py diff --git a/backend/deploy.py b/backend/deploy.py new file mode 100644 index 00000000..b551b44f --- /dev/null +++ b/backend/deploy.py @@ -0,0 +1,63 @@ +# Standard library imports +import os +import shutil +import zipfile + +# Third-party imports +from flask import request +from flask_restful import Resource + +# Our imports +from main import basedir + +class DeployResource(Resource): + def post(self): + """Upload and extract a zip file to the deploy directory""" + if 'file' not in request.files: + return {'error': 'No file provided'}, 400 + + file = request.files['file'] + + if file.filename == '': + return {'error': 'No file selected'}, 400 + + if not file.filename.endswith('.zip'): + return {'error': 'Only zip files are allowed'}, 400 + + try: + # Create deploy directory if it doesn't exist + deploy_dir = os.path.join(basedir, 'deploy') + + # Clear existing deploy directory + if os.path.exists(deploy_dir): + shutil.rmtree(deploy_dir) + os.makedirs(deploy_dir) + + # Save the zip file temporarily + temp_zip_path = os.path.join(basedir, 'temp_deploy.zip') + file.save(temp_zip_path) + + # Extract the zip file + with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: + zip_ref.extractall(deploy_dir) + + # Remove the temporary zip file + os.remove(temp_zip_path) + + # List extracted files + extracted_files = [] + for root, dirs, files in os.walk(deploy_dir): + for filename in files: + rel_path = os.path.relpath(os.path.join(root, filename), deploy_dir) + extracted_files.append(rel_path) + + return { + 'message': 'Deployment successful', + 'deploy_directory': deploy_dir, + 'files_extracted': len(extracted_files), + 'files': extracted_files[:20] # Show first 20 files + } + except zipfile.BadZipFile: + return {'error': 'Invalid zip file'}, 400 + except Exception as e: + return {'error': f'Deployment failed: {str(e)}'}, 500 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 295f7aff..21dfade0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,8 +2,6 @@ import argparse import json import os -import shutil -import zipfile # Third-party imports from flask import Flask, jsonify, request, send_from_directory @@ -11,6 +9,9 @@ from flask_sqlalchemy import SQLAlchemy from werkzeug.utils import secure_filename +# Our imports +from deploy import DeployResource + app = Flask(__name__, static_folder='../dist', static_url_path='') app.url_map.merge_slashes = False # Don't merge consecutive slashes api = Api(app) @@ -232,58 +233,6 @@ def get(self): return {'files': sorted(list(children))} -class DeployResource(Resource): - def post(self): - """Upload and extract a zip file to the deploy directory""" - if 'file' not in request.files: - return {'error': 'No file provided'}, 400 - - file = request.files['file'] - - if file.filename == '': - return {'error': 'No file selected'}, 400 - - if not file.filename.endswith('.zip'): - return {'error': 'Only zip files are allowed'}, 400 - - try: - # Create deploy directory if it doesn't exist - deploy_dir = os.path.join(basedir, 'deploy') - - # Clear existing deploy directory - if os.path.exists(deploy_dir): - shutil.rmtree(deploy_dir) - os.makedirs(deploy_dir) - - # Save the zip file temporarily - temp_zip_path = os.path.join(basedir, 'temp_deploy.zip') - file.save(temp_zip_path) - - # Extract the zip file - with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: - zip_ref.extractall(deploy_dir) - - # Remove the temporary zip file - os.remove(temp_zip_path) - - # List extracted files - extracted_files = [] - for root, dirs, files in os.walk(deploy_dir): - for filename in files: - rel_path = os.path.relpath(os.path.join(root, filename), deploy_dir) - extracted_files.append(rel_path) - - return { - 'message': 'Deployment successful', - 'deploy_directory': deploy_dir, - 'files_extracted': len(extracted_files), - 'files': extracted_files[:20] # Show first 20 files - } - except zipfile.BadZipFile: - return {'error': 'Invalid zip file'}, 400 - except Exception as e: - return {'error': f'Deployment failed: {str(e)}'}, 500 - # Register API routes # Storage API routes (more specific routes first) api.add_resource(StorageEntryResource, '/entries/') From 59f1cfa856327358bacf9617f899f50945d1fee1 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 16:09:22 -0500 Subject: [PATCH 06/12] split storage into its own file --- backend/main.py | 201 +-------------------------------------------- backend/storage.py | 199 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 198 deletions(-) create mode 100644 backend/storage.py diff --git a/backend/main.py b/backend/main.py index 21dfade0..a15cff22 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,20 @@ # Standard library imports import argparse -import json import os # Third-party imports from flask import Flask, jsonify, request, send_from_directory -from flask_restful import Api, Resource +from flask_restful import Api from flask_sqlalchemy import SQLAlchemy -from werkzeug.utils import secure_filename # Our imports from deploy import DeployResource +from storage import StorageEntryResource, StorageFileRenameResource, StorageRootResource, StorageResource app = Flask(__name__, static_folder='../dist', static_url_path='') app.url_map.merge_slashes = False # Don't merge consecutive slashes api = Api(app) +db = SQLAlchemy(app) # Add CORS headers @app.after_request @@ -38,201 +38,6 @@ def handle_preflight(): app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "projects.db")}' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) - -# Storage models for key-value pairs and files -class StorageEntry(db.Model): - id = db.Column(db.Integer, primary_key=True) - entry_key = db.Column(db.String(255), nullable=False, unique=True) - entry_value = db.Column(db.Text, nullable=False) - - def to_dict(self): - return { - 'key': self.entry_key, - 'value': self.entry_value - } - -class StorageFile(db.Model): - id = db.Column(db.Integer, primary_key=True) - file_path = db.Column(db.String(500), nullable=False, unique=True) - file_content = db.Column(db.Text, nullable=False) - - def to_dict(self): - return { - 'path': self.file_path, - 'content': self.file_content - } - -# Storage Resources for key-value and file operations -class StorageEntryResource(Resource): - def get(self, entry_key): - """Fetch entry value by key""" - entry = StorageEntry.query.filter_by(entry_key=entry_key).first() - if entry: - return {'value': entry.entry_value} - else: - # Return default value if provided in query params - default_value = request.args.get('default', '') - return {'value': default_value} - - def post(self, entry_key): - """Save entry value""" - data = request.get_json() - if not data or 'value' not in data: - return {'error': 'Entry value is required'}, 400 - - entry = StorageEntry.query.filter_by(entry_key=entry_key).first() - if entry: - entry.entry_value = data['value'] - else: - entry = StorageEntry(entry_key=entry_key, entry_value=data['value']) - db.session.add(entry) - - try: - db.session.commit() - return {'message': 'Entry saved successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to save entry'}, 500 - -class StorageResource(Resource): - def get(self, path): - """Get file content or list directory based on path""" - # Handle empty path as root directory - if not path: - path = "" - - if path.endswith('/') or path == "": - # List directory - # For root directory, find all files - if path == "": - files = StorageFile.query.all() - # Extract top-level files and directories - children = set() - for file in files: - if '/' in file.file_path: - # It's in a subdirectory, add the directory name - dir_name = file.file_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a top-level file - children.add(file.file_path) - else: - # Find all files that start with this path - files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() - - # Extract immediate children (not nested) - children = set() - for file in files: - relative_path = file.file_path[len(path):] - if '/' in relative_path: - # It's in a subdirectory, add the directory name - dir_name = relative_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a direct child file - children.add(relative_path) - - return {'files': sorted(list(children))} - else: - # Get file content - file_obj = StorageFile.query.filter_by(file_path=path).first() - if file_obj: - return {'content': file_obj.file_content} - else: - return {'error': 'File not found'}, 404 - - def post(self, path): - """Save file content (only for files, not directories)""" - if path.endswith('/'): - return {'error': 'Cannot save content to a directory path'}, 400 - - data = request.get_json() - if not data or 'content' not in data: - return {'error': 'File content is required'}, 400 - - file_obj = StorageFile.query.filter_by(file_path=path).first() - if file_obj: - file_obj.file_content = data['content'] - else: - file_obj = StorageFile(file_path=path, file_content=data['content']) - db.session.add(file_obj) - - try: - db.session.commit() - return {'message': 'File saved successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to save file'}, 500 - - def delete(self, path): - """Delete file or directory""" - if path.endswith('/'): - # Delete directory (all files starting with this path) - files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() - for file_obj in files: - db.session.delete(file_obj) - else: - # Delete specific file - file_obj = StorageFile.query.filter_by(file_path=path).first() - if not file_obj: - return {'error': 'File not found'}, 404 - db.session.delete(file_obj) - - try: - db.session.commit() - return {'message': 'Deleted successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to delete'}, 500 - -class StorageFileRenameResource(Resource): - def post(self): - """Rename file or directory""" - data = request.get_json() - if not data or 'old_path' not in data or 'new_path' not in data: - return {'error': 'Both old_path and new_path are required'}, 400 - - old_path = data['old_path'] - new_path = data['new_path'] - - if old_path.endswith('/'): - # Rename directory (all files starting with old_path) - files = StorageFile.query.filter(StorageFile.file_path.like(f'{old_path}%')).all() - for file_obj in files: - new_file_path = file_obj.file_path.replace(old_path, new_path, 1) - file_obj.file_path = new_file_path - else: - # Rename specific file - file_obj = StorageFile.query.filter_by(file_path=old_path).first() - if not file_obj: - return {'error': 'File not found'}, 404 - file_obj.file_path = new_path - - try: - db.session.commit() - return {'message': 'Renamed successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to rename'}, 500 - -class StorageRootResource(Resource): - def get(self): - """List all top-level files and directories""" - files = StorageFile.query.all() - # Extract top-level files and directories - children = set() - for file in files: - if '/' in file.file_path: - # It's in a subdirectory, add the directory name - dir_name = file.file_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a top-level file - children.add(file.file_path) - - return {'files': sorted(list(children))} - # Register API routes # Storage API routes (more specific routes first) api.add_resource(StorageEntryResource, '/entries/') diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 00000000..a02d8c2c --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,199 @@ +# Third-party imports +from flask import request +from flask_restful import Resource + +# Our imports +from main import db + +# Storage models for key-value pairs and files +class StorageEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + entry_key = db.Column(db.String(255), nullable=False, unique=True) + entry_value = db.Column(db.Text, nullable=False) + + def to_dict(self): + return { + 'key': self.entry_key, + 'value': self.entry_value + } + +class StorageFile(db.Model): + id = db.Column(db.Integer, primary_key=True) + file_path = db.Column(db.String(500), nullable=False, unique=True) + file_content = db.Column(db.Text, nullable=False) + + def to_dict(self): + return { + 'path': self.file_path, + 'content': self.file_content + } + +# Storage Resources for key-value and file operations +class StorageEntryResource(Resource): + def get(self, entry_key): + """Fetch entry value by key""" + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + return {'value': entry.entry_value} + else: + # Return default value if provided in query params + default_value = request.args.get('default', '') + return {'value': default_value} + + def post(self, entry_key): + """Save entry value""" + data = request.get_json() + if not data or 'value' not in data: + return {'error': 'Entry value is required'}, 400 + + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + entry.entry_value = data['value'] + else: + entry = StorageEntry(entry_key=entry_key, entry_value=data['value']) + db.session.add(entry) + + try: + db.session.commit() + return {'message': 'Entry saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save entry'}, 500 + +class StorageResource(Resource): + def get(self, path): + """Get file content or list directory based on path""" + # Handle empty path as root directory + if not path: + path = "" + + if path.endswith('/') or path == "": + # List directory + # For root directory, find all files + if path == "": + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + else: + # Find all files that start with this path + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + + # Extract immediate children (not nested) + children = set() + for file in files: + relative_path = file.file_path[len(path):] + if '/' in relative_path: + # It's in a subdirectory, add the directory name + dir_name = relative_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a direct child file + children.add(relative_path) + + return {'files': sorted(list(children))} + else: + # Get file content + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + return {'content': file_obj.file_content} + else: + return {'error': 'File not found'}, 404 + + def post(self, path): + """Save file content (only for files, not directories)""" + if path.endswith('/'): + return {'error': 'Cannot save content to a directory path'}, 400 + + data = request.get_json() + if not data or 'content' not in data: + return {'error': 'File content is required'}, 400 + + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + file_obj.file_content = data['content'] + else: + file_obj = StorageFile(file_path=path, file_content=data['content']) + db.session.add(file_obj) + + try: + db.session.commit() + return {'message': 'File saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save file'}, 500 + + def delete(self, path): + """Delete file or directory""" + if path.endswith('/'): + # Delete directory (all files starting with this path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + for file_obj in files: + db.session.delete(file_obj) + else: + # Delete specific file + file_obj = StorageFile.query.filter_by(file_path=path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + db.session.delete(file_obj) + + try: + db.session.commit() + return {'message': 'Deleted successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to delete'}, 500 + +class StorageFileRenameResource(Resource): + def post(self): + """Rename file or directory""" + data = request.get_json() + if not data or 'old_path' not in data or 'new_path' not in data: + return {'error': 'Both old_path and new_path are required'}, 400 + + old_path = data['old_path'] + new_path = data['new_path'] + + if old_path.endswith('/'): + # Rename directory (all files starting with old_path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{old_path}%')).all() + for file_obj in files: + new_file_path = file_obj.file_path.replace(old_path, new_path, 1) + file_obj.file_path = new_file_path + else: + # Rename specific file + file_obj = StorageFile.query.filter_by(file_path=old_path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + file_obj.file_path = new_path + + try: + db.session.commit() + return {'message': 'Renamed successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to rename'}, 500 + +class StorageRootResource(Resource): + def get(self): + """List all top-level files and directories""" + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + + return {'files': sorted(list(children))} From cf95e0432f125716e4686400ec9438f37ff71321 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 16:12:28 -0500 Subject: [PATCH 07/12] Change to use logger --- backend/main.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/main.py b/backend/main.py index a15cff22..9a97ba11 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ # Standard library imports import argparse +import logging import os # Third-party imports @@ -16,6 +17,10 @@ api = Api(app) db = SQLAlchemy(app) +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + # Add CORS headers @app.after_request def after_request(response): @@ -66,14 +71,14 @@ def serve_frontend(path): path = path.lstrip('/') # Debug logging - print(f"Requested path: '{path}'") + logger.debug(f"Requested path: '{path}'") # If path is empty, serve index.html if path == '': try: return send_from_directory(app.static_folder, 'index.html') except Exception as e: - print(f"Error serving index.html: {e}") + logger.error(f"Error serving index.html: {e}") return jsonify({ 'error': 'Frontend not built', 'message': 'Please build the frontend first with "npm run build"' @@ -81,10 +86,10 @@ def serve_frontend(path): # Try to serve the requested file try: - print(f"Attempting to serve: {app.static_folder}/{path}") + logger.debug(f"Attempting to serve: {app.static_folder}/{path}") return send_from_directory(app.static_folder, path) except Exception as e: - print(f"Error serving file: {e}") + logger.error(f"Error serving file: {e}") # If file not found and not an asset, serve index.html for client-side routing # But if it's an asset or known file type, return 404 if path.startswith('assets/') or '.' in path.split('/')[-1]: @@ -129,10 +134,16 @@ def serve_static(path): parser = argparse.ArgumentParser(description='Run the Storage API backend server') parser.add_argument('-p', '--port', type=int, default=5001, help='Port to run the server on (default: 5001)') + parser.add_argument('-l', '--log-level', type=str, default='INFO', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + help='Set the logging level (default: INFO)') args = parser.parse_args() + # Set logging level based on argument + logging.getLogger().setLevel(getattr(logging, args.log_level)) + with app.app_context(): db.create_all() - print(f"Starting server on port {args.port}...") + logger.info(f"Starting server on port {args.port}...") app.run(debug=True, port=args.port) \ No newline at end of file From 897bef977ac28b57d01414b520b801140448555d Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 16:16:04 -0500 Subject: [PATCH 08/12] add python 3 type hints --- backend/deploy.py | 3 ++- backend/main.py | 13 +++++++------ backend/storage.py | 21 ++++++++++++--------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/deploy.py b/backend/deploy.py index b551b44f..6208acf4 100644 --- a/backend/deploy.py +++ b/backend/deploy.py @@ -2,6 +2,7 @@ import os import shutil import zipfile +from typing import Dict, List, Tuple, Union # Third-party imports from flask import request @@ -11,7 +12,7 @@ from main import basedir class DeployResource(Resource): - def post(self): + def post(self) -> Union[Dict[str, Union[str, int, List[str]]], Tuple[Dict[str, str], int]]: """Upload and extract a zip file to the deploy directory""" if 'file' not in request.files: return {'error': 'No file provided'}, 400 diff --git a/backend/main.py b/backend/main.py index 9a97ba11..e73bc2bc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,9 +2,10 @@ import argparse import logging import os +from typing import Tuple, Union # Third-party imports -from flask import Flask, jsonify, request, send_from_directory +from flask import Flask, jsonify, request, send_from_directory, Response from flask_restful import Api from flask_sqlalchemy import SQLAlchemy @@ -23,14 +24,14 @@ # Add CORS headers @app.after_request -def after_request(response): +def after_request(response: Response) -> Response: response.headers.add('Access-Control-Allow-Origin', '*') response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') return response @app.before_request -def handle_preflight(): +def handle_preflight() -> Union[Response, None]: if request.method == "OPTIONS": response = jsonify({'message': 'OK'}) response.headers.add('Access-Control-Allow-Origin', '*') @@ -53,7 +54,7 @@ def handle_preflight(): # API health check endpoint to distinguish from static file serving @app.route('/api/status') -def api_status(): +def api_status() -> Response: """Health check endpoint to identify backend server""" return jsonify({ 'status': 'ok', @@ -65,7 +66,7 @@ def api_status(): @app.route('/blocks/', defaults={'path': ''}) @app.route('/blocks/') @app.route('/blocks//') # Handle double slash -def serve_frontend(path): +def serve_frontend(path: str) -> Union[Response, Tuple[Response, int]]: """Serve static assets from dist/ directory with base path""" # Normalize path - remove leading slashes and clean up double slashes path = path.lstrip('/') @@ -101,7 +102,7 @@ def serve_frontend(path): @app.route('/', defaults={'path': ''}) @app.route('/') -def serve_static(path): +def serve_static(path: str) -> Union[Response, Tuple[Response, int]]: """Serve static assets from dist/ directory""" # If path is empty, serve index.html if path == '': diff --git a/backend/storage.py b/backend/storage.py index a02d8c2c..c3125172 100644 --- a/backend/storage.py +++ b/backend/storage.py @@ -1,3 +1,6 @@ +# Standard library imports +from typing import Dict, List, Tuple, Union + # Third-party imports from flask import request from flask_restful import Resource @@ -11,7 +14,7 @@ class StorageEntry(db.Model): entry_key = db.Column(db.String(255), nullable=False, unique=True) entry_value = db.Column(db.Text, nullable=False) - def to_dict(self): + def to_dict(self) -> Dict[str, str]: return { 'key': self.entry_key, 'value': self.entry_value @@ -22,7 +25,7 @@ class StorageFile(db.Model): file_path = db.Column(db.String(500), nullable=False, unique=True) file_content = db.Column(db.Text, nullable=False) - def to_dict(self): + def to_dict(self) -> Dict[str, str]: return { 'path': self.file_path, 'content': self.file_content @@ -30,7 +33,7 @@ def to_dict(self): # Storage Resources for key-value and file operations class StorageEntryResource(Resource): - def get(self, entry_key): + def get(self, entry_key: str) -> Dict[str, str]: """Fetch entry value by key""" entry = StorageEntry.query.filter_by(entry_key=entry_key).first() if entry: @@ -40,7 +43,7 @@ def get(self, entry_key): default_value = request.args.get('default', '') return {'value': default_value} - def post(self, entry_key): + def post(self, entry_key: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: """Save entry value""" data = request.get_json() if not data or 'value' not in data: @@ -61,7 +64,7 @@ def post(self, entry_key): return {'error': 'Failed to save entry'}, 500 class StorageResource(Resource): - def get(self, path): + def get(self, path: str) -> Union[Dict[str, Union[List[str], str]], Tuple[Dict[str, str], int]]: """Get file content or list directory based on path""" # Handle empty path as root directory if not path: @@ -107,7 +110,7 @@ def get(self, path): else: return {'error': 'File not found'}, 404 - def post(self, path): + def post(self, path: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: """Save file content (only for files, not directories)""" if path.endswith('/'): return {'error': 'Cannot save content to a directory path'}, 400 @@ -130,7 +133,7 @@ def post(self, path): db.session.rollback() return {'error': 'Failed to save file'}, 500 - def delete(self, path): + def delete(self, path: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: """Delete file or directory""" if path.endswith('/'): # Delete directory (all files starting with this path) @@ -152,7 +155,7 @@ def delete(self, path): return {'error': 'Failed to delete'}, 500 class StorageFileRenameResource(Resource): - def post(self): + def post(self) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: """Rename file or directory""" data = request.get_json() if not data or 'old_path' not in data or 'new_path' not in data: @@ -182,7 +185,7 @@ def post(self): return {'error': 'Failed to rename'}, 500 class StorageRootResource(Resource): - def get(self): + def get(self) -> Dict[str, List[str]]: """List all top-level files and directories""" files = StorageFile.query.all() # Extract top-level files and directories From f1cfc91139485fec0629f8bbde521e40988ab3d9 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 16:17:23 -0500 Subject: [PATCH 09/12] Fix comment --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index e73bc2bc..1b7c5d79 100644 --- a/backend/main.py +++ b/backend/main.py @@ -68,7 +68,7 @@ def api_status() -> Response: @app.route('/blocks//') # Handle double slash def serve_frontend(path: str) -> Union[Response, Tuple[Response, int]]: """Serve static assets from dist/ directory with base path""" - # Normalize path - remove leading slashes and clean up double slashes + # Normalize path - remove leading slashes path = path.lstrip('/') # Debug logging From 7f57ea3b8e77836a93e50e926146f1ea16ad8b12 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Mon, 24 Nov 2025 16:19:54 -0500 Subject: [PATCH 10/12] Change /api/status to /statusz --- backend/main.py | 4 ++-- src/storage/server_side_storage.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/main.py b/backend/main.py index 1b7c5d79..77f565c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -53,8 +53,8 @@ def handle_preflight() -> Union[Response, None]: api.add_resource(DeployResource, '/deploy') # API health check endpoint to distinguish from static file serving -@app.route('/api/status') -def api_status() -> Response: +@app.route('/statusz') +def statusz() -> Response: """Health check endpoint to identify backend server""" return jsonify({ 'status': 'ok', diff --git a/src/storage/server_side_storage.ts b/src/storage/server_side_storage.ts index e13ceca3..bb8077a6 100644 --- a/src/storage/server_side_storage.ts +++ b/src/storage/server_side_storage.ts @@ -32,13 +32,11 @@ export async function isServerAvailable(): Promise { }); // Check the specific API status endpoint to distinguish backend from static file server - // Use absolute path without base URL since /api/status is a backend endpoint + // Use absolute path without base URL since /statusz is a backend endpoint const response = await Promise.race([ - fetch(`${API_BASE_URL}/api/status`), + fetch('/statusz'), timeoutPromise - ]); - - if (!response.ok) { + ]); if (!response.ok) { return false; } From d8cfd5f1b47d5b4b0d66449a387ea32aa2dd52ab Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Tue, 25 Nov 2025 12:36:22 -0500 Subject: [PATCH 11/12] Fixed mistake of combining lines --- src/storage/server_side_storage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/storage/server_side_storage.ts b/src/storage/server_side_storage.ts index bb8077a6..284baf15 100644 --- a/src/storage/server_side_storage.ts +++ b/src/storage/server_side_storage.ts @@ -36,7 +36,9 @@ export async function isServerAvailable(): Promise { const response = await Promise.race([ fetch('/statusz'), timeoutPromise - ]); if (!response.ok) { + ]); + + if (!response.ok) { return false; } From c2870eb3bc11919fe357f981f92c248deef4ee9a Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Wed, 26 Nov 2025 06:42:53 -0500 Subject: [PATCH 12/12] Fix problem introduced in merge --- src/reactComponents/Menu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactComponents/Menu.tsx b/src/reactComponents/Menu.tsx index c394b825..5651b5ca 100644 --- a/src/reactComponents/Menu.tsx +++ b/src/reactComponents/Menu.tsx @@ -294,7 +294,7 @@ export function Component(props: MenuProps): React.JSX.Element { } try { - const blobUrl = await createPythonFiles.producePythonProjectBlob(props.project, props.storage); + const blobUrl = await createPythonFiles.producePythonProjectBlob(props.currentProject, props.storage); // Check if the backend server is available const serverAvailable = await serverSideStorage.isServerAvailable(); @@ -306,7 +306,7 @@ export function Component(props: MenuProps): React.JSX.Element { const blob = await response.blob(); const formData = new FormData(); - formData.append('file', blob, `${props.project.projectName}.zip`); + formData.append('file', blob, `${props.currentProject.projectName}.zip`); const deployResponse = await fetch('/deploy', { method: 'POST', @@ -326,7 +326,7 @@ export function Component(props: MenuProps): React.JSX.Element { // Download the file locally const link = document.createElement('a'); link.href = blobUrl; - link.download = `${props.project.projectName}.zip`; + link.download = `${props.currentProject.projectName}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link);