diff --git a/backend/.tes_instances b/backend/.tes_instances index 2bfea85..3396567 100644 --- a/backend/.tes_instances +++ b/backend/.tes_instances @@ -10,7 +10,6 @@ TESK/Kubernetes @ ELIXIR-CZ (NA),https://tesk-na.cloud.e-infra.cz/ TESK/Kubernetes @ ELIXIR-DE,https://tesk.elixir-cloud.bi.denbi.de/ TESK/Kubernetes @ ELIXIR-GR,https://tesk-eu.hypatia-comp.athenarc.gr/ TESK/OpenShift @ ELIXIR-FI,https://csc-tesk-noauth.rahtiapp.fi/ -TESK North America,https://tesk-na.cloud.e-infra.cz/ # Local Development Local TES,http://localhost:8080 diff --git a/backend/routes/instances.py b/backend/routes/instances.py index 815c399..4b2247a 100644 --- a/backend/routes/instances.py +++ b/backend/routes/instances.py @@ -28,6 +28,27 @@ def get_healthy_instances_route(): print(f"❌ Error in get_healthy_instances: {str(e)}") return jsonify({'error': str(e)}), 500 +@instances_bp.route('/api/instances-with-status', methods=['GET']) +def get_instances_with_status(): + """Get all TES instances with their current health status""" + try: + from utils.tes_utils import load_tes_location_data + instances = load_tes_location_data() + + # Fetch status for all instances in parallel + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(fetch_tes_status, instances)) + + return jsonify({ + 'instances': results, + 'count': len(results), + 'last_updated': datetime.now(timezone.utc).isoformat() + }) + + except Exception as e: + print(f"Error in get_instances_with_status: {str(e)}") + return jsonify({'error': str(e)}), 500 + @instances_bp.route('/api/tes_locations', methods=['GET']) def tes_locations(): from utils.tes_utils import load_tes_location_data diff --git a/backend/routes/tasks.py b/backend/routes/tasks.py index ee5c77c..7c0af59 100644 --- a/backend/routes/tasks.py +++ b/backend/routes/tasks.py @@ -4,9 +4,13 @@ import json import time import requests +import shlex from services.task_service import get_submitted_tasks, add_task, update_single_task_status from utils.tes_utils import load_tes_instances from utils.auth_utils import get_instance_credentials +import logging + +logger = logging.getLogger(__name__) tasks_bp = Blueprint('tasks', __name__) @@ -36,9 +40,38 @@ def submit_task(): tes_name = inst['name'] break + + command_raw = data.get('command', '') + if isinstance(command_raw, list): + command = command_raw + elif command_raw: + + shell_operators = ['&&', '||', '|', '>', '<', '>>', '2>', '&', ';', '$(', '`'] + needs_shell = any(op in command_raw for op in shell_operators) + + if needs_shell: + + command = ["/bin/sh", "-c", command_raw] + print(f"🐚 Command contains shell operators, wrapping in shell: {command}") + else: + + try: + command = shlex.split(command_raw) + print(f"📝 Simple command parsed: {command}") + except ValueError as e: + logger.error(f"Command parsing error: {e}") + return jsonify({ + 'success': False, + 'error': f'Invalid command syntax: {str(e)}', + 'error_type': 'bad_request', + 'error_code': 'INVALID_COMMAND' + }), 400 + else: + command = ['echo', 'Hello World'] + executor = { "image": docker_image, - "command": data.get('command') if isinstance(data.get('command'), list) else (data.get('command', '').split() if data.get('command') else ['echo', 'Hello World']), + "command": command, "workdir": data.get('workdir', '/tmp') } @@ -100,13 +133,24 @@ def submit_task(): try: print(f" Trying service-info: {service_info_url}") - test_response = requests.get(service_info_url, timeout=10) - if test_response.status_code in [200, 403]: + test_response = requests.get(service_info_url, timeout=10) + + if test_response.status_code == 200: service_is_reachable = True working_endpoint = tasks_url print(f" ✅ Service reachable at {service_info_url} (status {test_response.status_code})") print(f" Will use tasks endpoint: {tasks_url}") break + elif test_response.status_code in [401, 403]: + # Authentication required - treat as unreachable since we can't use it + print(f" 🔐 Authentication required at {service_info_url} (status {test_response.status_code})") + connectivity_error_info = { + 'error_type': 'unauthorized', + 'error_code': 'UNAUTHORIZED', + 'message': 'Authentication required - TES instance requires credentials', + 'reason': 'This TES instance requires authentication. Please configure TESK_PROD_TOKEN or credentials in environment variables.' + } + continue else: print(f" ⚠️ Service returned status {test_response.status_code}") @@ -199,6 +243,7 @@ def submit_task(): tes_endpoint = working_endpoint print(f"🚀 Submitting task to {tes_endpoint}") + print(f"📦 Task payload: {json.dumps(tes_task, indent=2)}") credentials = get_instance_credentials(tes_name, tes_url) headers = { @@ -301,7 +346,7 @@ def submit_task(): else: error_type_map = { 400: {'error_type': 'bad_request', 'error_code': 'BAD_REQUEST', 'reason': 'The task specification is invalid or malformed'}, - 401: {'error_type': 'unauthorized', 'error_code': 'UNAUTHORIZED', 'reason': 'Authentication required or credentials are invalid'}, + 401: {'error_type': 'unauthorized', 'error_code': 'UNAUTHORIZED', 'reason': 'Authentication required. Please configure TESK_PROD_TOKEN or TESK_PROD_USER/TESK_PROD_PASSWORD environment variables for this instance.'}, 403: {'error_type': 'forbidden', 'error_code': 'FORBIDDEN', 'reason': 'You do not have permission to submit tasks to this instance'}, 404: {'error_type': 'not_found', 'error_code': 'NOT_FOUND', 'reason': 'The TES endpoint was not found. Check if the URL is correct.'}, 408: {'error_type': 'timeout', 'error_code': 'TIMEOUT', 'reason': 'The request timed out. The TES instance may be overloaded.'}, @@ -319,12 +364,21 @@ def submit_task(): }) error_msg = f'TES submission failed with status {response.status_code}' + print(f"❌ TES returned status {response.status_code}") + print(f"Response headers: {dict(response.headers)}") + print(f"Response body: {response.text[:500]}") + try: error_data = response.json() + print(f"Error data JSON: {error_data}") if error_data.get('message'): error_msg = error_data.get('message') elif error_data.get('error'): error_msg = error_data.get('error') + elif error_data.get('detail'): + error_msg = error_data.get('detail') + elif error_data.get('title'): + error_msg = error_data.get('title') else: error_msg = f'{error_msg}: {str(error_data)}' except: diff --git a/backend/services/tes_service.py b/backend/services/tes_service.py index c65f2a3..2f656c6 100644 --- a/backend/services/tes_service.py +++ b/backend/services/tes_service.py @@ -28,7 +28,46 @@ def fetch_tes_status(instance): r = requests.get(f"{tes_base_url}/ga4gh/tes/v1/service-info", timeout=5) latency_ms = int((time.time() - start_time) * 1000) - status = "healthy" if r.status_code == 200 else "unhealthy" + # First check service-info endpoint + if r.status_code in [401, 403]: + status = "unhealthy" # Authentication required but not available + elif r.status_code != 200: + status = "unhealthy" + else: + # Service-info is accessible, but we need to check if tasks endpoint is usable + # Try a HEAD/OPTIONS request to tasks endpoint to see if it requires auth + try: + instance_name = instance.get("name", "") + credentials = get_instance_credentials(instance_name, tes_base_url) + + # Test tasks endpoint with credentials (if available) + headers = {'Accept': 'application/json'} + auth = None + if credentials.get('token'): + headers['Authorization'] = f"Bearer {credentials['token']}" + elif credentials.get('user') and credentials.get('password'): + auth = (credentials['user'], credentials['password']) + + # Try to list tasks (with view=MINIMAL to reduce payload) + tasks_response = requests.get( + f"{tes_base_url}/ga4gh/tes/v1/tasks?view=MINIMAL", + headers=headers, + auth=auth, + timeout=5 + ) + + # If tasks endpoint returns 401/403, mark as unhealthy (auth required but not configured) + if tasks_response.status_code in [401, 403]: + print(f"⚠️ {instance.get('name')} tasks endpoint requires authentication (status {tasks_response.status_code})") + status = "unhealthy" + else: + # Tasks endpoint is accessible (200) or returns other non-auth error + status = "healthy" + except Exception as tasks_error: + print(f"⚠️ Could not check tasks endpoint for {instance.get('name')}: {tasks_error}") + # If we can't check tasks endpoint, assume healthy based on service-info + status = "healthy" + version = "" try: version = r.json().get("version", "") diff --git a/frontend/src/hooks/useInstances.js b/frontend/src/hooks/useInstances.js index ae9f8bd..de3d9d1 100644 --- a/frontend/src/hooks/useInstances.js +++ b/frontend/src/hooks/useInstances.js @@ -4,6 +4,7 @@ import instanceService from '../services/instanceService'; const useInstances = () => { const [state, setState] = useState({ instances: [], + allInstances: [], loading: true, error: null, lastUpdate: null @@ -15,7 +16,11 @@ const useInstances = () => { }; instanceService.addListener(handleUpdate); const initialState = instanceService.getHealthyInstances(); - setState(initialState); + const allInstancesState = instanceService.getAllInstancesWithStatus(); + setState({ + ...initialState, + allInstances: allInstancesState.instances + }); return () => { instanceService.removeListener(handleUpdate); }; @@ -26,6 +31,7 @@ const useInstances = () => { return { instances: state.instances, + allInstances: state.allInstances, loading: state.loading, error: state.error, lastUpdate: state.lastUpdate, diff --git a/frontend/src/pages/SubmitTask.js b/frontend/src/pages/SubmitTask.js index 162cbf5..c793ffe 100644 --- a/frontend/src/pages/SubmitTask.js +++ b/frontend/src/pages/SubmitTask.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { testConnection } from '../services/api'; @@ -196,11 +196,33 @@ const SubmitTask = () => { const { instances, + allInstances, loading: instancesLoading, error: instancesError, refresh: refreshInstances } = useInstances(); + // Helper function to get status badge + const getStatusBadge = (status) => { + return status === 'healthy' ? '✅' : '❌'; + }; + + useEffect(() => { + // Auto-select first healthy instance if no instance is selected + if (instances.length > 0 && !formData.tes_instance) { + // Try to find a healthy instance from allInstances (if available) + const healthyInstance = (allInstances.length > 0 ? allInstances : instances).find( + instance => instance.status === 'healthy' + ); + + // Only set default if a healthy instance exists + if (healthyInstance) { + setFormData(prev => ({ ...prev, tes_instance: healthyInstance.url })); + } + } + + }, [instances, allInstances]); + const handleTestConnection = async () => { try { setTestingConnection(true); @@ -238,46 +260,53 @@ const SubmitTask = () => { }; const getDemoTaskData = (demoType = 'basic') => { - const defaultTesInstance = instances.length > 0 - ? instances[0].url - : 'https://csc-tesk-noauth.rahtiapp.fi/v1/tasks'; + // Use first healthy instance as default for demos + let defaultTesInstance = ''; + + if (instances.length > 0) { + const healthyInstance = (allInstances.length > 0 ? allInstances : instances).find( + instance => instance.status === 'healthy' + ); + // Only use an instance if it's healthy, otherwise leave empty + defaultTesInstance = healthyInstance ? healthyInstance.url : ''; + } const demoTasks = { basic: { tes_instance: defaultTesInstance, task_name: 'Demo Hello World Task', - docker_image: 'ubuntu:20.04', - command: 'echo "Hello from TES Demo Task!" && echo "Current time: $(date)" && echo "System info: $(uname -a)" && echo "Task completed successfully"', + docker_image: 'alpine:latest', + command: 'echo "Hello from TES!" && date && uname -a', input_url: '', output_url: '', cpu_cores: '1', ram_gb: '1', - disk_gb: '5', - description: 'A simple demo task that prints system information and a hello message. Safe to run and completes quickly for testing purposes.' + disk_gb: '1', + description: 'Simple demo task using Alpine Linux (5MB). Note: If you see SYSTEM_ERROR, the TES instance may be experiencing infrastructure issues. Try a different instance or wait a few minutes.' }, python: { tes_instance: defaultTesInstance, task_name: 'Demo Python Script Task', - docker_image: 'python:3.9-slim', - command: 'python3 -c "import sys; import datetime; print(f\'Hello from Python {sys.version}\'); print(f\'Current time: {datetime.datetime.now()}\'); print(\'Demo task completed successfully!\')"', + docker_image: 'python:3.11-alpine', + command: 'python3 -c "import sys; import datetime; print(sys.version); print(datetime.datetime.now())"', input_url: '', output_url: '', cpu_cores: '1', ram_gb: '1', - disk_gb: '5', - description: 'A Python demo task that prints version info and timestamp using a Python container.' + disk_gb: '1', + description: 'Python demo using Alpine-based image (51MB). Faster than standard Python images.' }, fileops: { tes_instance: defaultTesInstance, task_name: 'Demo File Operations Task', - docker_image: 'ubuntu:20.04', - command: 'echo "Creating demo files..." && echo "Hello World" > /tmp/output.txt && echo "File contents:" && cat /tmp/output.txt && ls -la /tmp/', + docker_image: 'alpine:latest', + command: 'echo "Hello World" > /tmp/demo.txt && cat /tmp/demo.txt && ls -lh /tmp/demo.txt', input_url: '', output_url: '', cpu_cores: '1', ram_gb: '1', - disk_gb: '5', - description: 'A demo task that creates and reads files, demonstrating basic file operations within the container.' + disk_gb: '1', + description: 'Demonstrates file creation and reading in Alpine Linux.' } }; @@ -287,15 +316,7 @@ const SubmitTask = () => { const handleRunDemo = (demoType = 'basic') => { const demoData = getDemoTaskData(demoType); setFormData(demoData); - setError(null); - - const taskNames = { - basic: 'Basic Hello World', - python: 'Python Script', - fileops: 'File Operations' - }; - - alert(`${taskNames[demoType] || 'Demo'} task data loaded! Review the form and click "Submit Task" when ready.`); + setError(null); }; const handleSubmit = async (e) => { @@ -329,12 +350,7 @@ const SubmitTask = () => { const result = await taskService.submitTask(submitData); console.log('Task submission result:', result); - - if (result && result.message) { - alert(`Success: ${result.message}`); - } else { - alert('Task submitted successfully!'); - } + navigate('/tasks'); } catch (err) { console.error('Task submission error:', err); @@ -440,11 +456,23 @@ const SubmitTask = () => { required > - {instances.map((instance, index) => ( - - ))} + {(allInstances.length > 0 ? allInstances : instances) + .slice() + .sort((a, b) => { + // Sort by status: healthy (reachable without auth) first, then others + // Note: Backend marks instances requiring auth as 'unhealthy' + if (a.status === 'healthy' && b.status !== 'healthy') return -1; + if (a.status !== 'healthy' && b.status === 'healthy') return 1; + return 0; + }) + .map((instance) => ( + + ))} diff --git a/frontend/src/pages/Utilities.js b/frontend/src/pages/Utilities.js index 52824bf..977bb3b 100644 --- a/frontend/src/pages/Utilities.js +++ b/frontend/src/pages/Utilities.js @@ -406,7 +406,7 @@ const Utilities = () => { )); const serviceInfo = response.data; - let successMessage = `✅ Connection Test Successful!\n\nInstance: ${instanceName}\nResponse Time: ${responseTime}ms`; + let successMessage = `Connection Test Successful!\n\nInstance: ${instanceName}\nResponse Time: ${responseTime}ms`; if (serviceInfo && serviceInfo.name) { successMessage += `\nService Name: ${serviceInfo.name}`; @@ -417,7 +417,7 @@ const Utilities = () => { alert(successMessage); } catch (error) { - let errorMessage = `❌ Connection Test Failed\n\nInstance: ${instanceName}\nURL: ${url}\n\n`; + let errorMessage = `Connection Test Failed\n\nInstance: ${instanceName}\nURL: ${url}\n\n`; let errorReason = ''; let errorCode = ''; let errorType = ''; diff --git a/frontend/src/services/instanceService.js b/frontend/src/services/instanceService.js index 19b8dde..3f75910 100644 --- a/frontend/src/services/instanceService.js +++ b/frontend/src/services/instanceService.js @@ -3,6 +3,7 @@ import api from './api'; class InstanceService { constructor() { this.healthyInstances = []; + this.allInstancesWithStatus = []; this.loading = false; this.error = null; this.lastUpdate = null; @@ -24,6 +25,7 @@ class InstanceService { try { callback({ instances: this.healthyInstances, + allInstances: this.allInstancesWithStatus, loading: this.loading, error: this.error, lastUpdate: this.lastUpdate @@ -43,6 +45,15 @@ class InstanceService { }; } + getAllInstancesWithStatus() { + return { + instances: this.allInstancesWithStatus, + loading: this.loading, + error: this.error, + lastUpdate: this.lastUpdate + }; + } + async fetchHealthyInstances() { try { const isInitialLoad = this.healthyInstances.length === 0; @@ -50,24 +61,31 @@ class InstanceService { if (isInitialLoad) { this.loading = true; this.notifyListeners(); - console.log('🔄 Loading initial healthy TES instances...'); + console.log('🔄 Loading TES instances with status...'); } else { - console.log('🔄 Refreshing cached healthy TES instances...'); + console.log('🔄 Refreshing TES instances with status...'); } - const response = await api.get('/api/healthy-instances', { - timeout: 5000 + const response = await api.get('/api/instances-with-status', { + timeout: 10000 }); const data = response.data; - this.healthyInstances = data.instances || []; + const allInstances = data.instances || []; + + // Filter to only healthy instances, but keep status info + this.healthyInstances = allInstances.filter(inst => inst.status === 'healthy'); + + // Store all instances (including unhealthy) for dropdown display + this.allInstancesWithStatus = allInstances; + this.lastUpdate = data.last_updated ? new Date(data.last_updated) : new Date(); this.error = null; - console.log(`✅ Got ${this.healthyInstances.length} cached healthy TES instances (updated: ${this.lastUpdate.toLocaleTimeString()})`); + console.log(`✅ Got ${this.healthyInstances.length} healthy instances out of ${allInstances.length} total (updated: ${this.lastUpdate.toLocaleTimeString()})`); } catch (err) { - console.error('Error fetching healthy instances:', err); + console.error('Error fetching instances with status:', err); if (err.code === 'ECONNABORTED') { this.error = 'Connection timeout - using cached data'; @@ -84,11 +102,7 @@ class InstanceService { console.log(`Using ${this.healthyInstances.length} cached healthy instances during error`); } } finally { - if (this.healthyInstances.length === 0) { - this.loading = false; - } else { - this.loading = false; - } + this.loading = false; this.notifyListeners(); } }