diff --git a/.eslintrc.json b/.eslintrc.json index 9016441..9d49e79 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,6 +12,7 @@ "es2020": true, "node": true }, + "root": true, "rules": { "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], "@typescript-eslint/explicit-module-boundary-types": "off", diff --git a/lc_conductor/__init__.py b/lc_conductor/__init__.py index a25e97a..802f12e 100644 --- a/lc_conductor/__init__.py +++ b/lc_conductor/__init__.py @@ -5,31 +5,59 @@ ## SPDX-License-Identifier: Apache-2.0 ############################################################################### -from lc_conductor.backend_manager import ActionManager, TaskManager -from lc_conductor.callback_logger import CallbackLogger -from lc_conductor.tool_registration import ( - ToolList, - list_server_urls, - list_server_tools, - validate_and_register_mcp_server, - check_registered_servers, - delete_registered_server, - get_registered_servers, - try_get_public_hostname, -) -from lc_conductor.backend_helper_function import RunSettings +from importlib import import_module -__all__ = [ - "ActionManager", - "TaskManager", - "CallbackLogger", - "ToolList", - "list_server_urls", - "list_server_tools", - "validate_and_register_mcp_server", - "check_registered_servers", - "delete_registered_server", - "get_registered_servers", - "try_get_public_hostname", - "RunSettings", -] + +_EXPORTS = { + "ActionManager": ("lc_conductor.backend_manager", "ActionManager"), + "TaskManager": ("lc_conductor.backend_manager", "TaskManager"), + "CallbackLogger": ("lc_conductor.callback_logger", "CallbackLogger"), + "ToolList": ("lc_conductor.tool_registration", "ToolList"), + "list_server_urls": ("lc_conductor.tool_registration", "list_server_urls"), + "list_server_tools": ("lc_conductor.tool_registration", "list_server_tools"), + "validate_and_register_mcp_server": ( + "lc_conductor.tool_registration", + "validate_and_register_mcp_server", + ), + "check_registered_servers": ( + "lc_conductor.tool_registration", + "check_registered_servers", + ), + "delete_registered_server": ( + "lc_conductor.tool_registration", + "delete_registered_server", + ), + "get_registered_servers": ( + "lc_conductor.tool_registration", + "get_registered_servers", + ), + "try_get_public_hostname": ( + "lc_conductor.tool_registration", + "try_get_public_hostname", + ), + "RunSettings": ("lc_conductor.backend_helper_function", "RunSettings"), + "parse_curl_command": ("lc_conductor.curl_parser", "parse_curl_command"), + "execute_curl_command": ("lc_conductor.curl_executor", "execute_curl_command"), + "execute_http_request": ("lc_conductor.curl_executor", "execute_http_request"), + "build_hpc_allocation_request": ( + "lc_conductor.hpc_allocation", + "build_hpc_allocation_request", + ), + "execute_hpc_allocation_from_env": ( + "lc_conductor.hpc_allocation", + "execute_hpc_allocation_from_env", + ), +} + +__all__ = list(_EXPORTS) + + +def __getattr__(name: str): + if name not in _EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, attr_name = _EXPORTS[name] + module = import_module(module_name) + value = getattr(module, attr_name) + globals()[name] = value + return value diff --git a/lc_conductor/backend_manager.py b/lc_conductor/backend_manager.py index 59990cd..c1ccf58 100644 --- a/lc_conductor/backend_manager.py +++ b/lc_conductor/backend_manager.py @@ -9,21 +9,35 @@ from fastapi import WebSocket import asyncio import os +import re import traceback from loguru import logger from lc_conductor.callback_logger import CallbackLogger from concurrent.futures import ProcessPoolExecutor -from charge.experiments.experiment import Experiment -from charge.clients.agent_factory import AgentFactory -from charge.clients.autogen import AutoGenBackend from functools import partial -from lc_conductor.tool_registration import ( - ToolList, - list_server_urls, - list_server_tools, -) from lc_conductor.backend_helper_function import RunSettings +from lc_conductor.hpc_allocation import execute_hpc_allocation_from_env + +try: + from charge.experiments.experiment import Experiment + from charge.clients.agent_factory import AgentFactory + from charge.clients.autogen import AutoGenBackend +except ImportError: + Experiment = Any # type: ignore[assignment] + AgentFactory = None + AutoGenBackend = None + +try: + from lc_conductor.tool_registration import ( + ToolList, + list_server_urls, + list_server_tools, + ) +except ImportError: + ToolList = None # type: ignore[assignment] + list_server_urls = None + list_server_tools = None # Mapping from backend name to human-readable labels. Mirrored from the frontend BACKEND_LABELS = { @@ -320,3 +334,151 @@ async def handle_get_username(self, _: dict) -> None: "username": self.username, } ) + + async def handle_allocate_hpc_resources(self, data: dict) -> None: + request_id = data.get("requestId") + allocation = data.get("allocation") or {} + + # timestamp: keep consistent with curl executor tests ("...Z") + from datetime import datetime + + def _result_payload( + *, + executed_request: str | None, + result: dict, + allocation_echo: dict, + ) -> dict: + payload = { + "type": "allocate-hpc-resources-result", + "requestId": request_id, + "allocation": allocation_echo, + "result": result, + } + if executed_request: + payload["executedRequest"] = executed_request + return payload + + if not isinstance(request_id, str) or not request_id.strip(): + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": "Missing or invalid requestId", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo=allocation if isinstance(allocation, dict) else {}, + ) + ) + return + + if not isinstance(allocation, dict): + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": "Missing or invalid allocation object", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo={}, + ) + ) + return + + system = allocation.get("system") + nodes = allocation.get("nodes") + wall_time = allocation.get("time") + bank = allocation.get("bank") + + if not isinstance(system, str) or not system.strip(): + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": "Missing or invalid system", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo=allocation, + ) + ) + return + + try: + # Accept int or numeric string. + if isinstance(nodes, str): + if not re.fullmatch(r"\d+", nodes.strip()): + raise ValueError("nodes must be a positive integer") + nodes_int = int(nodes.strip()) + elif isinstance(nodes, int): + nodes_int = nodes + else: + raise ValueError("nodes must be a positive integer") + + if nodes_int < 1: + raise ValueError("nodes must be >= 1") + except ValueError as e: + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": str(e), + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo=allocation, + ) + ) + return + + if not isinstance(wall_time, str) or not wall_time.strip(): + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": "Missing or invalid time", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo=allocation, + ) + ) + return + + if not isinstance(bank, str) or not bank.strip(): + await self.websocket.send_json( + _result_payload( + executed_request=None, + result={ + "success": False, + "error": "Missing or invalid bank", + "timestamp": datetime.utcnow().isoformat() + "Z", + }, + allocation_echo=allocation, + ) + ) + return + + allocation_echo = { + "system": system.strip(), + "nodes": nodes_int, + "time": wall_time.strip(), + "bank": bank.strip(), + } + + executed_request, result = await execute_hpc_allocation_from_env( + system=allocation_echo["system"], + nodes=allocation_echo["nodes"], + time=allocation_echo["time"], + bank=allocation_echo["bank"], + client_info=f"ws:{self.username}", + ) + + await self.websocket.send_json( + _result_payload( + executed_request=executed_request, + result=result, + allocation_echo=allocation_echo, + ) + ) diff --git a/lc_conductor/curl_executor.py b/lc_conductor/curl_executor.py new file mode 100644 index 0000000..dbd865b --- /dev/null +++ b/lc_conductor/curl_executor.py @@ -0,0 +1,118 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +from datetime import datetime +import time +import requests +from typing import Dict, Any +from loguru import logger +from lc_conductor.curl_parser import parse_curl_command + + +async def execute_http_request( + *, + method: str, + url: str, + client_info: str, + headers: dict[str, str] | None = None, + data: Any = None, + timeout: float = 30.0, + allow_redirects: bool = True, + log_label: str = "HTTP request", + log_detail: str | None = None, +) -> Dict[str, Any]: + """Execute an HTTP request with consistent logging and result formatting.""" + detail = f": {log_detail[:100]}..." if log_detail else "" + logger.info(f"{log_label} from {client_info}{detail}") + + start_time = time.time() + timestamp = datetime.utcnow().isoformat() + "Z" + + try: + response = requests.request( + method=method, + url=url, + headers=headers or {}, + data=data, + timeout=timeout, + allow_redirects=allow_redirects, + ) + + execution_time = (time.time() - start_time) * 1000 + + return { + "success": True, + "status_code": response.status_code, + "headers": dict(response.headers), + "body": response.text, + "execution_time_ms": execution_time, + "timestamp": timestamp, + } + + except requests.exceptions.Timeout: + return { + "success": False, + "error": f"Request timed out after {timeout:g} seconds", + "execution_time_ms": (time.time() - start_time) * 1000, + "timestamp": timestamp, + } + except requests.exceptions.ConnectionError as e: + return { + "success": False, + "error": f"Connection failed: {str(e)}", + "execution_time_ms": (time.time() - start_time) * 1000, + "timestamp": timestamp, + } + except Exception as e: + logger.error(f"{log_label} error: {e}") + return { + "success": False, + "error": f"Execution failed: {str(e)}", + "execution_time_ms": (time.time() - start_time) * 1000, + "timestamp": timestamp, + } + + +async def execute_curl_command(curl_command: str, client_info: str) -> Dict[str, Any]: + """ + Execute curl command using requests library. + + Args: + curl_command: The curl command string + client_info: Client identification for logging + + Returns: + { + 'success': bool, + 'status_code': int, + 'headers': dict, + 'body': str, + 'error': str, + 'execution_time_ms': float, + 'timestamp': str + } + """ + try: + parsed = parse_curl_command(curl_command) + return await execute_http_request( + method=parsed["method"], + url=parsed["url"], + headers=parsed["headers"], + data=parsed["data"], + timeout=parsed["timeout"], + allow_redirects=True, + client_info=client_info, + log_label="Curl execution", + log_detail=curl_command, + ) + except ValueError as e: + return { + "success": False, + "error": f"Invalid command: {str(e)}", + "execution_time_ms": 0.0, + "timestamp": datetime.utcnow().isoformat() + "Z", + } diff --git a/lc_conductor/curl_parser.py b/lc_conductor/curl_parser.py new file mode 100644 index 0000000..ff0b5d3 --- /dev/null +++ b/lc_conductor/curl_parser.py @@ -0,0 +1,99 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +import shlex +import re +from typing import Dict, Any +from urllib.parse import urlparse + + +def parse_curl_command(curl_command: str) -> Dict[str, Any]: + """ + Parse curl command into HTTP request components. + + Security validations: + - Only http/https protocols allowed + - Rejects shell metacharacters + - Maximum 10,000 character length + + Returns: + { + 'method': str, # GET, POST, etc. + 'url': str, + 'headers': Dict[str, str], + 'data': Optional[str], + 'timeout': float + } + """ + # Validate length + if len(curl_command) > 10000: + raise ValueError("Command too long (max 10,000 chars)") + + # Check for shell metacharacters + dangerous_chars = [";", "|", "`", "$", "(", ")"] + if any(char in curl_command for char in dangerous_chars): + raise ValueError("Command contains unsafe shell characters") + if re.search(r"(^|\s)&|&($|\s)|&&", curl_command): + raise ValueError("Command contains unsafe shell characters") + + # Parse with shlex (safe tokenization) + try: + parts = shlex.split(curl_command) + except ValueError as e: + raise ValueError(f"Invalid command syntax: {e}") + + # First token must be 'curl' + if not parts or parts[0] != "curl": + raise ValueError("Command must start with 'curl'") + + # Extract components + method = "GET" + url = None + headers = {} + data = None + + i = 1 + while i < len(parts): + arg = parts[i] + + if arg in ["-X", "--request"]: + i += 1 + if i < len(parts): + method = parts[i].upper() + elif arg in ["-H", "--header"]: + i += 1 + if i < len(parts): + header = parts[i] + if ":" in header: + key, value = header.split(":", 1) + headers[key.strip()] = value.strip() + elif arg in ["-d", "--data", "--data-raw"]: + i += 1 + if i < len(parts): + data = parts[i] + elif not arg.startswith("-"): + # This is likely the URL + url = arg + # Skip other flags we don't support + + i += 1 + + if not url: + raise ValueError("No URL found in command") + + # Validate URL + parsed = urlparse(url) + if parsed.scheme not in ["http", "https"]: + raise ValueError(f"Only http/https protocols allowed, got: {parsed.scheme}") + + return { + "method": method, + "url": url, + "headers": headers, + "data": data, + "timeout": 30.0, + } diff --git a/lc_conductor/hpc_allocation.py b/lc_conductor/hpc_allocation.py new file mode 100644 index 0000000..787c3e7 --- /dev/null +++ b/lc_conductor/hpc_allocation.py @@ -0,0 +1,132 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +from __future__ import annotations + +from datetime import datetime +import os +from typing import Any, Dict, Optional, Tuple + +from lc_conductor.curl_executor import execute_http_request + + +DEFAULT_TRIGGER_URL = ( + "https://lc.llnl.gov/gitlab/api/v4/projects/10734/trigger/pipeline" +) + + +def build_hpc_allocation_request( + *, + trigger_url: str, + trigger_token: str, + ref: str, + system: str, + nodes: int, + time: str, + bank: str, +) -> Dict[str, Any]: + if not trigger_url.strip(): + raise ValueError("Missing trigger URL") + if not trigger_token.strip(): + raise ValueError("Missing trigger token") + if not ref.strip(): + raise ValueError("Missing trigger ref") + + return { + "method": "POST", + "url": trigger_url.strip(), + "headers": {}, + "data": [ + ("token", trigger_token), + ("ref", ref.strip()), + ("variables[SYSTEM]", system), + ("variables[NODES]", str(nodes)), + ("variables[TIME]", time), + ("variables[BANK]", bank), + ], + "timeout": 30.0, + "allow_redirects": True, + } + + +def describe_hpc_allocation_request(request_spec: Dict[str, Any]) -> str: + redacted_pairs = [] + for key, value in request_spec["data"]: + safe_value = "" if key == "token" else value + redacted_pairs.append(f"{key}={safe_value}") + return f'{request_spec["method"]} {request_spec["url"]} form: ' + ", ".join( + redacted_pairs + ) + + +async def execute_hpc_allocation_from_env( + *, + system: str, + nodes: int, + time: str, + bank: str, + client_info: str, +) -> Tuple[Optional[str], Dict[str, Any]]: + """ + Build and execute an HPC allocation request using the GitLab trigger API. + + Env vars: + - FLASK_HPC_ALLOCATION_TRIGGER_URL: optional trigger endpoint URL + - FLASK_HPC_ALLOCATION_REF: optional Git ref, defaults to "main" + - FLASK_HPC_ALLOCATION_TOKEN: preferred trigger token + - GENESIS_RUNNER_TOKEN / MY_PERSONAL_TOKEN: fallback token names + """ + trigger_url = os.getenv( + "FLASK_HPC_ALLOCATION_TRIGGER_URL", DEFAULT_TRIGGER_URL + ).strip() + trigger_ref = os.getenv("FLASK_HPC_ALLOCATION_REF", "main").strip() + token = ( + os.getenv("FLASK_HPC_ALLOCATION_TOKEN", "").strip() + or os.getenv("GENESIS_RUNNER_TOKEN", "").strip() + or os.getenv("MY_PERSONAL_TOKEN", "").strip() + ) + timestamp = datetime.utcnow().isoformat() + "Z" + + if not token: + return None, { + "success": False, + "error": ( + "Missing HPC allocation trigger token. Set " + "FLASK_HPC_ALLOCATION_TOKEN, GENESIS_RUNNER_TOKEN, or MY_PERSONAL_TOKEN." + ), + "timestamp": timestamp, + } + + try: + request_spec = build_hpc_allocation_request( + trigger_url=trigger_url, + trigger_token=token, + ref=trigger_ref, + system=system, + nodes=nodes, + time=time, + bank=bank, + ) + except ValueError as e: + return None, { + "success": False, + "error": f"Invalid allocation request configuration: {e}", + "timestamp": timestamp, + } + + result = await execute_http_request( + method=request_spec["method"], + url=request_spec["url"], + headers=request_spec["headers"], + data=request_spec["data"], + timeout=request_spec["timeout"], + allow_redirects=request_spec["allow_redirects"], + client_info=client_info, + log_label="HPC allocation request", + log_detail=f'{request_spec["method"]} {request_spec["url"]}', + ) + return describe_hpc_allocation_request(request_spec), result diff --git a/lcc_ui_components/package.json b/lcc_ui_components/package.json index 3c619e0..a74b3ef 100644 --- a/lcc_ui_components/package.json +++ b/lcc_ui_components/package.json @@ -12,7 +12,8 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./style.css": "./dist/style.css" }, "files": [ "dist" diff --git a/lcc_ui_components/src/SettingsButton.tsx b/lcc_ui_components/src/SettingsButton.tsx index 85732f8..5c6284f 100644 --- a/lcc_ui_components/src/SettingsButton.tsx +++ b/lcc_ui_components/src/SettingsButton.tsx @@ -6,9 +6,10 @@ //############################################################################# import React from 'react'; -import { Plus, Trash2, Edit2, Loader2, Settings, Wrench } from 'lucide-react'; +import { Plus, Trash2, Edit2, Loader2, Settings, Wrench, Cpu } from 'lucide-react'; import { OrchestratorSettings, ToolServer, SettingsButtonProps } from './types.js'; import { BACKEND_OPTIONS } from './constants.js'; +import { AllocateHpcResources } from './allocate_hpc_resources.js'; export const SettingsButton: React.FC = ({ onClick, @@ -18,10 +19,13 @@ export const SettingsButton: React.FC = ({ initialSettings, username, httpServerUrl, + websocket, className = '', }) => { const [isModalOpen, setIsModalOpen] = React.useState(false); - const [activeTab, setActiveTab] = React.useState<'orchestrator' | 'tools'>('orchestrator'); + const [activeTab, setActiveTab] = React.useState<'orchestrator' | 'tools' | 'hpc'>( + 'orchestrator', + ); // Cache for storing backend-specific settings const [backendCache, setBackendCache] = React.useState< Record< @@ -44,6 +48,7 @@ export const SettingsButton: React.FC = ({ useCustomModel: false, apiKey: '', toolServers: [], + hpcAllocations: [], ...initialSettings, }; @@ -720,10 +725,25 @@ export const SettingsButton: React.FC = ({ {tempSettings.toolServers.length} )} + -
+
{/* Orchestrator Tab */} {activeTab === 'orchestrator' && (
@@ -1090,8 +1110,19 @@ export const SettingsButton: React.FC = ({ Add Tool Server )} -
- )} +
+ )} + + {/* HPC Tab */} + {activeTab === 'hpc' && ( + + setTempSettings({ ...tempSettings, hpcAllocations: next }) + } + /> + )}
diff --git a/lcc_ui_components/src/allocate_hpc_resources.tsx b/lcc_ui_components/src/allocate_hpc_resources.tsx new file mode 100644 index 0000000..a332e41 --- /dev/null +++ b/lcc_ui_components/src/allocate_hpc_resources.tsx @@ -0,0 +1,392 @@ +//############################################################################# +// Copyright 2025-2026 Lawrence Livermore National Security, LLC. +// See the top-level LICENSE file for details. +// +// SPDX-License-Identifier: Apache-2.0 +//############################################################################# + +import React from 'react'; +import { Plus, Loader2 } from 'lucide-react'; +import { CurlExecutionResult, HpcAllocation, HpcAllocationResultMessage } from './types.js'; + +type DraftRow = { + id: string; + system: string; + nodesText: string; + time: string; + bank: string; + status: 'idle' | 'pending' | 'error'; + error?: string; +}; + +export type AllocateHpcResourcesProps = { + websocket?: WebSocket; + savedAllocations: HpcAllocation[]; + onSavedAllocationsChange: (next: HpcAllocation[]) => void; +}; + +const generateId = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const parsePositiveInt = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const num = Number(trimmed); + if (!Number.isInteger(num) || num < 1) return null; + return num; +}; + +const isComplete = (row: DraftRow): boolean => { + return ( + row.system.trim().length > 0 && + parsePositiveInt(row.nodesText) !== null && + row.time.trim().length > 0 && + row.bank.trim().length > 0 + ); +}; + +export const AllocateHpcResources: React.FC = ({ + websocket, + savedAllocations, + onSavedAllocationsChange, +}) => { + const [draftRows, setDraftRows] = React.useState([ + { id: generateId(), system: '', nodesText: '', time: '', bank: '', status: 'idle' }, + ]); + const pendingByRequestIdRef = React.useRef>(new Map()); + const [resultsByRowId, setResultsByRowId] = React.useState>( + {}, + ); + const savedAllocationsRef = React.useRef(savedAllocations); + + React.useEffect(() => { + savedAllocationsRef.current = savedAllocations; + }, [savedAllocations]); + + React.useEffect(() => { + if (!websocket) return; + + const onMessage = (event: MessageEvent) => { + let data: unknown; + try { + data = JSON.parse(event.data); + } catch { + return; + } + + const msg = data as Partial; + if (msg.type !== 'allocate-hpc-resources-result' || !msg.requestId || !msg.result) return; + + const rowId = pendingByRequestIdRef.current.get(msg.requestId); + if (!rowId) return; + + pendingByRequestIdRef.current.delete(msg.requestId); + + setResultsByRowId((prev) => ({ ...prev, [rowId]: msg.result! })); + + if (msg.result!.success) { + const allocation = msg.allocation; + if (!allocation) return; + + onSavedAllocationsChange([ + ...(savedAllocationsRef.current || []), + { + id: generateId(), + system: allocation.system, + nodes: allocation.nodes, + time: allocation.time, + bank: allocation.bank, + }, + ]); + + setDraftRows((prev) => prev.filter((r) => r.id !== rowId)); + return; + } + + setDraftRows((prev) => + prev.map((r) => + r.id === rowId + ? { + ...r, + status: 'error', + error: msg.result!.error || 'Allocation failed', + } + : r, + ), + ); + }; + + websocket.addEventListener('message', onMessage); + return () => websocket.removeEventListener('message', onMessage); + }, [websocket, onSavedAllocationsChange]); + + const addRow = () => { + setDraftRows((prev) => [ + ...prev, + { id: generateId(), system: '', nodesText: '', time: '', bank: '', status: 'idle' }, + ]); + }; + + const deleteRow = (rowId: string) => { + setDraftRows((prev) => prev.filter((r) => r.id !== rowId)); + }; + + const updateRow = (rowId: string, patch: Partial) => { + setDraftRows((prev) => + prev.map((r) => (r.id === rowId ? { ...r, ...patch, status: 'idle', error: undefined } : r)), + ); + }; + + const saveRow = (row: DraftRow) => { + if (row.status === 'pending') return; + + const missing: string[] = []; + if (!row.system.trim()) missing.push('system'); + const nodes = parsePositiveInt(row.nodesText); + if (nodes === null) missing.push('nodes'); + if (!row.time.trim()) missing.push('time'); + if (!row.bank.trim()) missing.push('bank'); + + if (missing.length > 0) { + setDraftRows((prev) => + prev.map((r) => + r.id === row.id + ? { + ...r, + status: 'error', + error: `Missing/invalid: ${missing.join(', ')}`, + } + : r, + ), + ); + return; + } + + if (!websocket) { + setDraftRows((prev) => + prev.map((r) => + r.id === row.id + ? { ...r, status: 'error', error: 'WebSocket connection is required to submit' } + : r, + ), + ); + return; + } + + if (websocket.readyState !== WebSocket.OPEN) { + setDraftRows((prev) => + prev.map((r) => + r.id === row.id + ? { ...r, status: 'error', error: 'WebSocket is not open (connect first)' } + : r, + ), + ); + return; + } + + const requestId = generateId(); + pendingByRequestIdRef.current.set(requestId, row.id); + + setDraftRows((prev) => + prev.map((r) => (r.id === row.id ? { ...r, status: 'pending', error: undefined } : r)), + ); + + websocket.send( + JSON.stringify({ + type: 'allocate-hpc-resources', + requestId, + allocation: { + system: row.system.trim(), + nodes, + time: row.time.trim(), + bank: row.bank.trim(), + }, + }), + ); + }; + + return ( +
+
+
+

HPC Resources

+

Create and submit allocation requests

+
+ +
+ + {!websocket && ( +
+ HPC allocation requires a WebSocket connection. Pass `websocket` to `SettingsButton`. +
+ )} + +
+ {draftRows.map((row) => { + const complete = isComplete(row); + const result = resultsByRowId[row.id]; + const nodes = parsePositiveInt(row.nodesText); + + return ( +
+
+
+ + updateRow(row.id, { system: e.target.value })} + className="form-input" + placeholder="lassen" + disabled={row.status === 'pending'} + /> +
+ +
+ + updateRow(row.id, { nodesText: e.target.value })} + className="form-input" + placeholder="1" + disabled={row.status === 'pending'} + /> + {row.nodesText.trim().length > 0 && nodes === null && ( +
+

Enter a whole number ≥ 1

+
+ )} +
+ +
+ + updateRow(row.id, { time: e.target.value })} + className="form-input" + placeholder="01:00:00" + disabled={row.status === 'pending'} + /> +
+ +
+ + updateRow(row.id, { bank: e.target.value })} + className="form-input" + placeholder="mybank" + disabled={row.status === 'pending'} + /> +
+
+ +
+ + + + {(row.error || result?.error) && ( +
+

+ {row.error || 'Allocation failed'} + {result?.error ? `: ${result.error}` : ''} +

+
+ )} + + {result?.success && ( +
+

+ ✓ Submitted{result.status_code ? ` (${result.status_code})` : ''} +

+
+ )} +
+
+ ); + })} +
+ +
+

+ Saved Resources + {savedAllocations && savedAllocations.length > 0 && ( + + {savedAllocations.length} + + )} +

+ + {savedAllocations && savedAllocations.length > 0 ? ( +
+ {savedAllocations.map((alloc) => ( +
+
+
+
System
+
{alloc.system}
+
+
+
Nodes
+
{alloc.nodes}
+
+
+
Time
+
{alloc.time}
+
+
+
Bank
+
{alloc.bank}
+
+
+
+ ))} +
+ ) : ( +
+ No HPC resources saved yet. +
+ )} +
+
+ ); +}; diff --git a/lcc_ui_components/src/index.ts b/lcc_ui_components/src/index.ts index 1ad27c8..56496b4 100644 --- a/lcc_ui_components/src/index.ts +++ b/lcc_ui_components/src/index.ts @@ -22,6 +22,10 @@ export type { OrchestratorSettings, BackendOption, SettingsButtonProps, + CurlExecutionResult, + HpcAllocation, + HpcAllocationRequestMessage, + HpcAllocationResultMessage, // Sidebar types SidebarMessage, diff --git a/lcc_ui_components/src/style.css b/lcc_ui_components/src/style.css new file mode 100644 index 0000000..20b8286 --- /dev/null +++ b/lcc_ui_components/src/style.css @@ -0,0 +1,2160 @@ +@import 'tailwindcss'; + +/* ======================================== + DESIGN SYSTEM - FLASK Copilot + ======================================== */ + +/* Custom scrollbars - applies to all scrollable elements */ +.custom-scrollbar::-webkit-scrollbar, +.max-h-60::-webkit-scrollbar, +.max-h-96::-webkit-scrollbar, +.content-wrapper::-webkit-scrollbar, +.sidebar-content::-webkit-scrollbar, +.reasoning-messages::-webkit-scrollbar, +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track, +.max-h-60::-webkit-scrollbar-track, +.max-h-96::-webkit-scrollbar-track, +.content-wrapper::-webkit-scrollbar-track, +.sidebar-content::-webkit-scrollbar-track, +.reasoning-messages::-webkit-scrollbar-track, +*::-webkit-scrollbar-track { + background: rgba(139, 92, 246, 0.1); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb, +.max-h-60::-webkit-scrollbar-thumb, +.max-h-96::-webkit-scrollbar-thumb, +.content-wrapper::-webkit-scrollbar-thumb, +.sidebar-content::-webkit-scrollbar-thumb, +.reasoning-messages::-webkit-scrollbar-thumb, +*::-webkit-scrollbar-thumb { + background: rgba(139, 92, 246, 0.5); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover, +.max-h-60::-webkit-scrollbar-thumb:hover, +.max-h-96::-webkit-scrollbar-thumb:hover, +.content-wrapper::-webkit-scrollbar-thumb:hover, +.sidebar-content::-webkit-scrollbar-thumb:hover, +.reasoning-messages::-webkit-scrollbar-thumb:hover, +*::-webkit-scrollbar-thumb:hover { + background: rgba(139, 92, 246, 0.7); +} + +/* Firefox scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(139, 92, 246, 0.5) rgba(139, 92, 246, 0.1); +} + +/* ======================================== + LAYOUT + ======================================== */ + +.app-background { + min-height: 100vh; + background: linear-gradient(to bottom right, #0f172a, #581c87, #0f172a); +} + +.main-container { + display: flex; + min-height: 100vh; + width: 100%; + height: 100vh; + overflow: hidden; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.content-header { + position: relative; + width: 100%; + height: 0; + pointer-events: none; +} + +.content-wrapper { + flex: 1; + min-width: 0; + padding: 2rem; + overflow-y: auto; + overflow-x: hidden; + height: 100vh; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ======================================== + BUTTONS + ======================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.5rem; + border-radius: 0.5rem; + font-weight: 600; + transition: all 0.2s; + cursor: pointer; + border: none; + font-size: 0.875rem; + color: #ffffff; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: linear-gradient(to right, #9333ea, #ec4899); + color: #ffffff; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.btn-primary:hover:not(:disabled) { + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.btn-secondary { + background: rgba(168, 85, 247, 0.3); + color: #ffffff; +} + +.btn-secondary:hover:not(:disabled) { + background: rgba(168, 85, 247, 0.5); +} + +.btn-tertiary { + background: rgba(255, 255, 255, 0.2); + color: #ffffff; +} + +.btn-tertiary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.3); +} + +.btn-danger { + background: #dc2626; + color: #ffffff; +} + +.btn-danger:hover:not(:disabled) { + background: #ef4444; +} + +.btn-icon { + padding: 0.5rem; + border-radius: 0.5rem; + background: transparent; + color: #c4b5fd; + transition: all 0.2s; + cursor: pointer; + border: none; +} + +.btn-icon:hover { + color: #ffffff; + background: rgba(168, 85, 247, 0.3); +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* ======================================== + CARDS & PANELS + ======================================== */ + +.card { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(16px); + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.card-padding { + padding: 1.5rem; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid rgba(168, 85, 247, 0.3); +} + +.card-body { + padding: 1.5rem; +} + +.glass-panel { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(8px); + border-radius: 0.75rem; + padding: 1rem; + border: 1px solid rgba(168, 85, 247, 0.3); +} + +/* ======================================== + MODALS + ======================================== */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-content { + background: linear-gradient(to bottom right, #1e293b, #581c87); + border: 2px solid #a78bfa; + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + width: 100%; + padding: 1.5rem; +} + +.modal-content-sm { + max-width: 28rem; +} + +.modal-content-md { + max-width: 42rem; +} + +.modal-content-lg { + max-width: 48rem; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.modal-title { + font-size: 1.25rem; + font-weight: bold; + color: white; +} + +.modal-subtitle { + font-size: 0.875rem; + color: #d8b4fe; +} + +.modal-body { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.modal-footer { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +/* ======================================== + FORMS + ======================================== */ + +.form-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #ddd6fe; + margin-bottom: 0.5rem; +} + +.form-label-block { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #ddd6fe; + margin-bottom: 0.5rem; +} + +.form-input { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(168, 85, 247, 0.5); + border-radius: 0.5rem; + color: white; + font-family: ui-monospace, monospace; + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-input-text { + font-family: inherit; + background: rgba(255, 255, 255, 0.1); +} + +.form-input:focus { + outline: none; + border-color: #a78bfa; +} + +.form-input::placeholder { + color: rgba(216, 180, 254, 0.5); +} + +.form-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-select { + width: 100%; + padding: 0.625rem 1rem; + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(168, 85, 247, 0.5); + border-radius: 0.5rem; + color: white; + cursor: pointer; + font-size: 0.875rem; + transition: border-color 0.2s; +} + +.form-select:focus { + outline: none; + border-color: #a78bfa; +} + +.form-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-select option { + background: #1e293b; +} + +.form-textarea { + width: 100%; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(168, 85, 247, 0.5); + border-radius: 0.5rem; + color: white; + resize: none; + transition: border-color 0.2s; +} + +.form-textarea:focus { + outline: none; + border-color: #a78bfa; +} + +.form-textarea::placeholder { + color: rgba(216, 180, 254, 0.5); +} + +.form-checkbox { + width: 1rem; + height: 1rem; + border-radius: 0.25rem; + border: 1px solid rgba(168, 85, 247, 0.5); + background: rgba(255, 255, 255, 0.2); + color: #9333ea; + cursor: pointer; + flex-shrink: 0; +} + +.form-checkbox:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.3); +} + +.form-group { + margin-bottom: 1rem; +} + +/* ======================================== + DROPDOWNS + ======================================== */ + +.dropdown { + position: relative; +} + +.dropdown-menu { + position: absolute; + top: 100%; + margin-top: 0.5rem; + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + padding: 0.5rem 0; + min-width: 12rem; + z-index: 100; +} + +.dropdown-item { + width: 100%; + padding: 0.5rem 1rem; + text-align: left; + font-size: 0.875rem; + color: white; + background: transparent; + border: none; + cursor: pointer; + transition: background 0.2s; +} + +.dropdown-item:hover { + background: rgba(168, 85, 247, 0.5); +} + +.dropdown-divider { + border-top: 1px solid rgba(168, 85, 247, 0.3); + margin: 0.5rem 0; +} + +/* ======================================== + BADGES & TAGS + ======================================== */ + +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-primary { + background: rgba(168, 85, 247, 0.3); + color: #ddd6fe; +} + +.badge-success { + background: rgba(34, 197, 94, 0.3); + color: #bbf7d0; +} + +.badge-warning { + background: rgba(245, 158, 11, 0.3); + color: #fef3c7; +} + +.badge-danger { + background: rgba(239, 68, 68, 0.3); + color: #fecaca; +} + +.badge-computing { + background: #f59e0b; + color: white; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* ======================================== + STATUS INDICATORS + ======================================== */ + +.status-indicator { + width: 1rem; + height: 1rem; + border-radius: 9999px; + position: relative; +} + +.status-indicator-connected { + background: #4ade80; +} + +.status-indicator-disconnected { + background: #f87171; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.status-indicator-reconnecting { + background: #fbbf24; +} + +.status-indicator-ping { + position: absolute; + inset: 0; + border-radius: 9999px; + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +/* ======================================== + TOOLTIPS & POPOVERS + ======================================== */ + +.tooltip { + position: absolute; + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + white-space: nowrap; + z-index: 50; + pointer-events: none; +} + +.tooltip-content { + color: #ddd6fe; +} + +.ws-tooltip { + position: absolute; + right: 0; + top: 2rem; + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + padding: 0.75rem; + font-size: 0.875rem; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + min-width: 220px; + max-width: 300px; + z-index: 50; + transition: opacity 0.2s; +} + +.ws-status-indicator { + position: absolute; + top: 3.5rem; + right: 2rem; + z-index: 10; +} + +/* ======================================== + SIDEBAR + ======================================== */ + +.sidebar { + background: rgba(30, 41, 59, 0.5); + backdrop-filter: blur(4px); + border-right: 1px solid rgba(168, 85, 247, 0.3); + display: flex; + flex-direction: column; + position: relative; + flex-shrink: 0; + height: 100vh; + overflow: hidden; + transition: + width 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar.resizing { + transition: none !important; +} + +.sidebar-collapsed { + width: 3rem; + padding: 1rem 0; + align-items: center; + min-width: 3rem; + height: 100vh; +} + +.sidebar-right { + border-right: none; + border-left: 2px solid #a78bfa; +} + +.sidebar-inner { + background: rgba(50, 34, 86, 0.9); +} + +.sidebar-right.sidebar-collapsed { + width: 3rem !important; + min-width: 3rem; +} + +.sidebar-header { + padding: 1rem; + border-bottom: 1px solid rgba(168, 85, 247, 0.3); +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + min-height: 0; +} + +.sidebar-resize-handle { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 8px; + cursor: col-resize; + z-index: 10; + transition: background-color 0.2s; + user-select: none; + -webkit-user-select: none; +} + +.sidebar-resize-handle::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 2px; + background: rgba(168, 85, 247, 0.2); + transition: background 0.2s; +} + +.sidebar-resize-handle:hover::after { + background: rgba(168, 85, 247, 0.5); +} + +.sidebar-resize-handle:hover { + background-color: rgba(99, 102, 241, 0.1); +} + +.sidebar-resize-handle:active, +.sidebar-resize-handle.bg-secondary { + background-color: rgba(99, 102, 241, 0.2); +} + +.sidebar-resize-handle-left { + left: 0; + right: auto; +} + +.sidebar-resize-handle-left::after { + left: 0; + right: auto; +} + +/* Project Sidebar Specific */ +.project-item { + display: flex; + align-items: center; + gap: 0.5rem; + position: relative; +} + +.project-button { + flex: 1; + padding: 0.5rem 0.75rem; + text-align: left; + font-size: 0.875rem; + border-radius: 0.5rem; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + color: #ddd6fe; +} + +.project-button:hover:not(:disabled) { + color: white; + background: rgba(168, 85, 247, 0.3); +} + +.project-button-active { + background: rgba(168, 85, 247, 0.5); + color: white; + font-weight: 500; +} + +.project-button:disabled { + cursor: not-allowed; +} + +.experiment-button { + flex: 1; + padding: 0.375rem 0.75rem; + text-align: left; + font-size: 0.75rem; + border-radius: 0.5rem; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + color: #ddd6fe; +} + +.experiment-button:hover:not(:disabled) { + color: white; + background: rgba(168, 85, 247, 0.2); +} + +.experiment-button-active { + background: rgba(168, 85, 247, 0.5); + color: white; + font-weight: 500; +} + +.experiment-button:disabled { + cursor: not-allowed; +} + +.experiment-item { + padding-left: 2rem; +} + +.project-actions { + position: absolute; + right: 0.25rem; + display: flex; + align-items: center; + gap: 0.125rem; + opacity: 0; + transition: opacity 0.2s; +} + +.project-item:hover .project-actions { + opacity: 1; +} + +.project-actions-bg { + background: linear-gradient(to left, #1e293b, #1e293b, transparent); + padding-left: 2rem; + padding-right: 0.25rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + display: flex; + align-items: center; + gap: 0.125rem; +} + +.action-button { + padding: 0.25rem; + color: #d8b4fe; + background: rgba(30, 41, 59, 0.8); + backdrop-filter: blur(4px); + border-radius: 0.25rem; + transition: all 0.2s; + cursor: pointer; + border: none; +} + +.action-button:hover { + color: white; + background: rgba(168, 85, 247, 0.5); +} + +.action-button-danger:hover { + color: #fca5a5; + background: rgba(239, 68, 68, 0.3); +} + +.action-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Reasoning Sidebar */ +.reasoning-sidebar { + background: #0f172a; + border-left: 2px solid #a78bfa; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + flex-shrink: 0; + position: relative; + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.reasoning-sidebar.resizing { + transition: none !important; +} + +.reasoning-messages { + flex: 1; + overflow-y: auto; + padding: 1rem; + overflow-x: hidden; + min-height: 0; +} + +.message-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 0.5rem; + padding: 1rem; + border: 1px solid rgba(168, 85, 247, 0.3); + overflow-x: auto; + word-break: break-word; +} + +.message-card-new { + animation: slideIn 0.3s ease-out; + animation-fill-mode: forwards; + opacity: 0; +} + +.message-card-existing { + opacity: 1; +} + +/* ======================================== + GRAPH / CANVAS + ======================================== */ + +.graph-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +.graph-cursor-grab { + cursor: grab; +} + +.graph-cursor-grabbing { + cursor: grabbing; +} + +.graph-cursor-default { + cursor: default; +} + +.graph-canvas { + position: absolute; + transform-origin: 0 0; + transition: none; + width: 3000px; + height: 2000px; +} + +.graph-canvas-smooth { + transition: transform 0.1s ease-out; +} + +.graph-node { + position: absolute; + animation: fadeInScale 0.5s ease-out; + transition: + left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), + top 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.node-card { + background: linear-gradient(to bottom right, rgba(30, 41, 59, 0.9), rgba(88, 28, 135, 0.9)); + backdrop-filter: blur(4px); + border-radius: 0.75rem; + padding: 0.75rem; + border: 2px solid; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + transition: all 0.2s; + pointer-events: auto; + cursor: pointer; + text-align: center; +} + +.node-card:hover { + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + transform: scale(1.05); +} + +.graph-reaction { + position: absolute; + animation: fadeInScale 0.5s ease-out; + transition: + left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), + top 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.reaction-button { + backdrop-filter: blur(4px); + border-radius: 2rem; + border: 2px solid; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + transition: all 0.2s; + pointer-events: auto; + cursor: pointer; + text-align: center; + color: rgba(249, 240, 255, 0.8); +} + +.reaction-button:hover { + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + transform: scale(1.05); +} + +/* Node state styles */ +.node-normal { + background: linear-gradient(to bottom right, rgba(249, 240, 255, 0.8), rgba(249, 168, 212, 0.8)); + border-color: rgba(168, 85, 247, 0.3); +} + +.node-normal:hover { + border-color: #a78bfa; +} + +.node-error { + background: linear-gradient(to bottom right, rgba(245, 158, 11, 0.4), rgba(239, 68, 68, 1)); + border-color: #f87171; + box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.5); +} + +.node-computing { + background: linear-gradient(to bottom right, rgba(245, 158, 11, 0.4), rgba(234, 179, 8, 0.4)); + border-color: #fbbf24; + box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.5); + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Node state styles */ +.reaction-normal { + background: linear-gradient(to bottom right, rgba(245, 158, 11, 0.8), rgba(234, 179, 8, 0.8)); + border-color: rgba(168, 85, 247, 0.3); +} + +.reaction-normal:hover { + border-color: #dce67d; +} + +.reaction-highlight2 { + background: linear-gradient(to bottom right, rgba(32, 255, 0, 0.8), rgba(3, 106, 240, 0.8)); + border-color: #97ffd2; +} + +.reaction-highlight { + background: linear-gradient(to bottom right, rgba(146, 1, 242, 0.8), rgba(255, 0, 139, 0.8)); + border-color: #fbbf24; +} + +.node-label { + margin-top: 0.5rem; + text-align: center; +} + +.node-label-text { + font-size: 0.75rem; + font-weight: 600; + color: #ddd6fe; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + white-space: pre-line; +} + +/* Edge labels in graph */ +.edge-normal { + stroke: #8b5cf6; +} + +.edge-computing { + stroke: #f59e0b; +} + +.edge-highlighted { + stroke: #dce67d; +} + +.edge-label { + position: absolute; + pointer-events: auto; + transform: translate(-50%, -50%); +} + +.edge-label-badge { + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.edge-label-computing { + background: #f59e0b; + color: white; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.edge-label-normal { + background: #a855f7; + color: white; +} + +.graph-controls { + position: absolute; + bottom: 1rem; + left: 1rem; + pointer-events: none; +} + +.graph-control-panel { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + border-radius: 0.5rem; + padding: 0.75rem; + color: #ddd6fe; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.graph-control-panel-interactive { + pointer-events: auto; +} + +/* ======================================== + CONTEXT MENU + ======================================== */ + +.context-menu { + position: fixed; + z-index: 50; + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + padding: 0.5rem 0; + min-width: 12rem; + max-width: 30rem; +} + +.context-menu-header { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(168, 85, 247, 0.3); +} + +.context-menu-label { + font-size: 0.75rem; + color: #d8b4fe; +} + +.context-menu-title { + font-size: 0.875rem; + font-weight: 600; + color: white; +} + +.context-menu-item { + width: 100%; + padding: 0.5rem 1rem; + text-align: left; + font-size: 0.875rem; + color: white; + background: transparent; + border: none; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.context-menu-item:hover:not(:disabled) { + background: rgba(168, 85, 247, 0.5); +} + +.context-menu-item:disabled { + color: #9ca3af; + cursor: not-allowed; +} + +.context-menu-item:disabled:hover { + background: transparent; +} + +.context-menu-divider { + border-top: 1px solid rgba(168, 85, 247, 0.3); +} + +.context-menu-details { + margin: 0.5rem 0.75rem; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.5rem; + max-height: 12rem; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid rgba(168, 85, 247, 0.2); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.context-menu-details .markdown-content { + font-size: 0.8125rem; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.context-menu-details .markdown-paragraph { + font-size: 0.8125rem; + margin-bottom: 0.25rem; +} + +.context-menu-details .markdown-heading-1, +.context-menu-details .markdown-heading-2, +.context-menu-details .markdown-heading-3 { + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.context-menu-details .markdown-list-item { + font-size: 0.8125rem; + margin-left: 0.75rem; +} + +.context-menu-details .markdown-table { + font-size: 0.75rem; + width: 100%; +} + +.context-menu-details code { + word-break: break-all; +} + +/* ======================================== + ALERTS & NOTIFICATIONS + ======================================== */ + +.alert { + padding: 1rem 1.25rem; + border-radius: 0.75rem; + margin-top: 1.5rem; + backdrop-filter: blur(16px); +} + +.alert-success { + background: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.5); +} + +.alert-success-text { + display: flex; + align-items: center; + gap: 0.75rem; + color: #bbf7d0; +} + +.alert-warning { + background: rgba(245, 158, 11, 0.2); + border: 1px solid rgba(245, 158, 11, 0.5); + padding: 0.75rem 1rem; +} + +.alert-warning-text { + align-items: center; + gap: 0.75rem; + color: #fef3c7; +} + +.alert-info { + background: rgba(168, 85, 247, 0.2); + border: 1px solid rgba(168, 85, 247, 0.5); +} + +.alert-info-text { + display: flex; + align-items: center; + gap: 0.75rem; + color: #ddd6fe; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.notification-badge { + background: #ec4899; + color: white; + font-size: 0.75rem; + border-radius: 9999px; + width: 1.25rem; + height: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +/* ======================================== + COLOR SYSTEM + ======================================== */ + +/* Text Colors */ +.text-primary { + color: #ffffff; +} + +.text-secondary { + color: #ddd6fe; +} + +.text-tertiary { + color: #d8b4fe; +} + +.text-muted { + color: #a78bfa; +} + +.text-accent { + color: #c4b5fd; +} + +.text-success { + color: #bbf7d0; +} + +.text-warning { + color: #fef3c7; +} + +.text-danger { + color: #fecaca; +} + +.text-info { + color: #e9d5ff; +} + +/* Background Colors */ +.bg-primary { + background: #9333ea; +} + +.bg-secondary { + background: #7c3aed; +} + +.bg-surface { + background: rgba(255, 255, 255, 0.05); +} + +.bg-surface-hover { + background: rgba(255, 255, 255, 0.1); +} + +.bg-overlay { + background: rgba(0, 0, 0, 0.5); +} + +.bg-success { + background: rgba(34, 197, 94, 0.3); +} + +.bg-warning { + background: rgba(245, 158, 11, 0.3); +} + +.bg-danger { + background: rgba(239, 68, 68, 0.3); +} + +/* Border Colors */ +.border-primary { + border-color: #a78bfa; +} + +.border-secondary { + border-color: rgba(168, 85, 247, 0.3); +} + +.border-success { + border-color: rgba(34, 197, 94, 0.5); +} + +.border-warning { + border-color: rgba(245, 158, 11, 0.5); +} + +/* Status Colors */ +.status-connected { + color: #4ade80; +} + +.status-disconnected { + color: #f87171; +} + +.status-reconnecting { + color: #fbbf24; +} + +/* ======================================== + TYPOGRAPHY + ======================================== */ + +.heading-1 { + font-size: 2.25rem; + font-weight: bold; + color: #ffffff; +} + +.heading-2 { + font-size: 1.5rem; + font-weight: bold; + color: #ffffff; +} + +.heading-3 { + font-size: 1.25rem; + font-weight: 600; + color: #ffffff; +} + +.text-xs { + font-size: 0.75rem; +} + +.text-sm { + font-size: 0.875rem; +} + +.text-base { + font-size: 1rem; +} + +.text-lg { + font-size: 1.125rem; +} + +.text-mono { + font-family: ui-monospace, monospace; +} + +/* ======================================== + UTILITY CLASSES + ======================================== */ + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flex-col { + display: flex; + flex-direction: column; +} + +.gap-sm { + gap: 0.5rem; +} + +.gap-md { + gap: 0.75rem; +} + +.gap-lg { + gap: 1rem; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hover-lift:hover { + transform: translateY(-2px); +} + +.noselect { + user-select: none; + -webkit-user-select: none; +} + +/* ======================================== + TOGGLE SWITCH + ======================================== */ + +.toggle-switch { + position: relative; + width: 3.5rem; + height: 1.75rem; + border-radius: 9999px; + transition: background-color 0.2s; + cursor: pointer; +} + +.toggle-switch-on { + background: #9333ea; +} + +.toggle-switch-off { + background: rgba(168, 85, 247, 0.3); +} + +.toggle-switch-handle { + position: absolute; + top: 0.25rem; + left: 0.25rem; + width: 1.25rem; + height: 1.25rem; + background: white; + border-radius: 9999px; + transition: transform 0.2s; +} + +.toggle-switch-handle-on { + transform: translateX(1.75rem); +} + +.toggle-switch-handle-off { + transform: translateX(0); +} + +/* ======================================== + ANIMATIONS + ======================================== */ + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInSidebar { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes dash { + to { + stroke-dashoffset: -10; + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out; +} + +.animate-fadeInScale { + animation: fadeInScale 0.5s ease-out; +} + +.animate-slideIn { + animation: slideIn 0.3s ease-out; +} + +.animate-slideInSidebar { + animation: slideInSidebar 0.3s ease-out; +} + +.animate-dash { + animation: dash 0.5s linear infinite; +} + +/* ======================================== + CUSTOM COMPONENTS + ======================================== */ + +/* Logo SVG styling */ +.logo-svg { + height: 60px; +} + +/* Markdown rendering */ +.markdown-content { + color: #e9d5ff; +} + +.markdown-heading-1 { + font-size: 1.125rem; + font-weight: bold; + color: #ddd6fe; +} + +.markdown-heading-2 { + font-size: 1rem; + font-weight: 600; + color: #d8b4fe; +} + +.markdown-heading-3 { + font-size: 0.9375rem; + font-weight: 600; + color: #c4b5fd; +} + +.markdown-list-item { + margin-left: 1rem; + font-size: 0.875rem; + color: #e9d5ff; +} + +.markdown-numbered-list-item { + margin-left: 1rem; + font-size: 0.875rem; + color: #e9d5ff; +} + +.markdown-paragraph { + font-size: 0.875rem; + color: #e9d5ff; +} + +.markdown-strong { + font-weight: 600; +} + +.markdown-em { + font-style: italic; +} + +.markdown-spacer { + height: 0.25rem; +} + +.markdown-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + margin: 0.5rem 0; + table-layout: fixed; + word-break: break-word; +} + +.markdown-table-header { + background: rgba(88, 28, 135, 0.4); + padding: 0.5rem; + text-align: left; + font-weight: 600; + color: #ddd6fe; + border: 1px solid rgba(139, 92, 246, 0.3); +} + +.markdown-table-cell { + padding: 0.5rem; + border: 1px solid rgba(139, 92, 246, 0.2); + color: #e9d5ff; +} + +.markdown-table tbody tr:nth-child(even) { + background: rgba(88, 28, 135, 0.2); +} + +/* Icon containers */ +.icon-sm { + width: 1rem; + height: 1rem; +} + +.icon-md { + width: 1.25rem; + height: 1.25rem; +} + +.icon-lg { + width: 1.5rem; + height: 1.5rem; +} + +.icon-xl { + width: 2rem; + height: 2rem; +} + +/* Spacing utilities */ +.space-y-1 > * + * { + margin-top: 0.25rem; +} + +.space-y-2 > * + * { + margin-top: 0.5rem; +} + +.space-y-3 > * + * { + margin-top: 0.75rem; +} + +.space-y-4 > * + * { + margin-top: 1rem; +} + +.space-x-2 > * + * { + margin-left: 0.5rem; +} + +.space-x-3 > * + * { + margin-left: 0.75rem; +} + +/* Sizing utilities */ +.w-full { + width: 100%; +} + +.h-full { + height: 100%; +} + +.max-w-sm { + max-width: 24rem; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-lg { + max-width: 32rem; +} + +.max-w-xl { + max-width: 36rem; +} + +/* Position utilities */ +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.fixed { + position: fixed; +} + +/* Display utilities */ +.inline { + display: inline; +} + +.inline-block { + display: inline-block; +} + +.block { + display: block; +} + +/* Z-index utilities */ +.z-50 { + z-index: 50; +} + +.z-100 { + z-index: 100; +} + +/* Common component patterns */ +.timestamp { + font-size: 0.75rem; + color: #a78bfa; +} + +.count-badge { + font-size: 0.75rem; + color: #a78bfa; + flex-shrink: 0; +} + +.menu-item-text { + font-size: 0.875rem; + color: #ffffff; +} + +.helper-text { + font-size: 0.75rem; + color: #a78bfa; + margin-top: 0.25rem; +} + +.section-label { + font-size: 0.75rem; + font-weight: 600; + color: #d8b4fe; +} + +.emphasized-text { + font-weight: 500; + color: #ffffff; +} + +/* Font utilities */ +.font-semibold { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +.font-medium { + font-weight: 500; +} + +/* Opacity utilities */ +.opacity-50 { + opacity: 0.5; +} + +.opacity-90 { + opacity: 0.9; +} + +/* Rounded corners */ +.rounded { + border-radius: 0.25rem; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-full { + border-radius: 9999px; +} + +/* Padding utilities */ +.p-1 { + padding: 0.25rem; +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +/* Margin utilities */ +.mt-1 { + margin-top: 0.25rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-auto { + margin-left: auto; +} + +/* Flexbox utilities */ +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.flex-1 { + flex: 1; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +/* Cursor utilities */ +.cursor-pointer { + cursor: pointer; +} + +.cursor-not-allowed { + cursor: not-allowed; +} + +/* Transition utilities */ +.transition-all { + transition: all 0.2s; +} + +.transition-colors { + transition-property: color, background-color, border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Header with logo and title */ +.app-header { + text-align: center; + margin-bottom: 2rem; +} + +.app-header-content { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.app-title { + font-size: 2.25rem; + font-weight: bold; + color: white; +} + +.app-subtitle { + color: #d8b4fe; +} + +.app-logo { + padding-top: 0.5rem; + position: relative; +} + +.app-logo-right { + position: absolute; + top: 0; + right: 2rem; + z-index: 10; +} + +/* Input row styling */ +.input-row { + display: flex; + align-items: end; + gap: 1rem; + margin-bottom: 1rem; +} + +.input-row-controls { + display: flex; + align-items: end; + gap: 0.5rem; +} + +.input-row-actions { + display: flex; + gap: 0.75rem; + flex: 1; + flex-wrap: wrap; + justify-content: flex-end; +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #d8b4fe; +} + +.empty-state-icon { + width: 4rem; + height: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state-text { + text-align: center; + font-size: 1.125rem; +} + +.empty-state-subtext { + font-size: 0.875rem; + color: #a78bfa; + margin-top: 0.5rem; +} + +/* Footer */ +.app-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(168, 85, 247, 0.3); + text-align: center; + color: #d8b4fe; + font-size: 0.875rem; +} + +/* Tool list in modal */ +.tool-list { + max-height: 24rem; + overflow-y: auto; + margin-bottom: 1rem; +} + +.tool-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 0.5rem; + cursor: pointer; + transition: background 0.2s; +} + +.tool-list-item:hover { + background: rgba(255, 255, 255, 0.2); +} + +.tool-list-item input[type='checkbox'] { + width: 1rem; + height: 1rem; + accent-color: #a855f7; +} + +.tool-list-item-label { + color: white; +} + +/* Metrics dashboard */ +.metrics-grid { + display: grid; + gap: 1.5rem; +} + +.metrics-grid-single { + grid-template-columns: 1fr; +} + +.metrics-grid-multi { + grid-template-columns: repeat(2, 1fr); +} + +.metric-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 0.75rem; + padding: 1rem; +} + +.metric-title { + font-size: 0.875rem; + font-weight: 600; + color: #ddd6fe; + margin-bottom: 0.5rem; +} + +.metric-value { + margin-top: 0.5rem; + text-align: center; +} + +.metric-value-number { + font-size: 1.5rem; + font-weight: bold; + color: white; +} + +.metric-value-label { + font-size: 0.75rem; + color: #d8b4fe; +} + +/* Hover tooltip for graph nodes */ +.node-hover-tooltip { + position: fixed; + z-index: 50; + pointer-events: none; + max-width: 400px; +} + +.node-hover-content { + background: linear-gradient(to bottom right, #1e293b, #581c87); + border: 2px solid #a78bfa; + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + padding: 1rem; + max-height: 24rem; + overflow-y: auto; +} + +/* Warning icon with tooltip */ +.warning-tooltip { + margin-left: 0.5rem; + color: #fbbf24; + cursor: help; + position: relative; + display: inline-block; +} + +.warning-tooltip-content { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 0.5rem; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} + +.warning-tooltip:hover .warning-tooltip-content { + opacity: 0.9; +} + +.warning-tooltip-box { + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + white-space: nowrap; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + color: #ddd6fe; +} + +/* Filter controls */ +.filter-control { + position: relative; +} + +.filter-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background: #1e293b; + border: 2px solid #a78bfa; + border-radius: 0.5rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + padding: 0.5rem 0; + min-width: 12rem; + z-index: 100; +} + +.filter-menu-header { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(168, 85, 247, 0.3); + font-size: 0.75rem; + font-weight: 600; + color: #d8b4fe; +} + +.filter-menu-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + cursor: pointer; + transition: background 0.2s; +} + +.filter-menu-item:hover { + background: rgba(168, 85, 247, 0.3); +} + +.filter-menu-footer { + padding: 0.5rem 0.75rem; + border-top: 1px solid rgba(168, 85, 247, 0.3); + display: flex; + gap: 0.5rem; +} diff --git a/lcc_ui_components/src/types.ts b/lcc_ui_components/src/types.ts index d4facaf..43bb795 100644 --- a/lcc_ui_components/src/types.ts +++ b/lcc_ui_components/src/types.ts @@ -17,6 +17,48 @@ export interface ToolServer { name?: string; // Optional display name } +export interface CurlExecutionResult { + success: boolean; + status_code?: number; + headers?: Record; + body?: string; + error?: string; + execution_time_ms?: number; + timestamp: string; // When executed +} + +export interface HpcAllocation { + id: string; + system: string; + nodes: number; + time: string; + bank: string; +} + +export interface HpcAllocationRequestMessage { + type: 'allocate-hpc-resources'; + requestId: string; + allocation: { + system: string; + nodes: number; + time: string; + bank: string; + }; +} + +export interface HpcAllocationResultMessage { + type: 'allocate-hpc-resources-result'; + requestId: string; + allocation: { + system: string; + nodes: number; + time: string; + bank: string; + }; + executedRequest?: string; + result: CurlExecutionResult; +} + export interface OrchestratorSettings { backend: string; useCustomUrl: boolean; @@ -26,6 +68,7 @@ export interface OrchestratorSettings { apiKey: string; backendLabel: string; toolServers?: ToolServer[]; + hpcAllocations?: HpcAllocation[]; } export interface BackendOption { @@ -49,6 +92,7 @@ export interface SettingsButtonProps { username?: string; className?: string; httpServerUrl: string; + websocket?: WebSocket; } // ============================================================================ diff --git a/lcc_ui_components/vite.config.ts b/lcc_ui_components/vite.config.ts index 313749d..9cbb607 100644 --- a/lcc_ui_components/vite.config.ts +++ b/lcc_ui_components/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { copyFileSync } from 'fs'; export default defineConfig({ plugins: [ @@ -11,6 +12,14 @@ export default defineConfig({ include: ['src/**/*.ts', 'src/**/*.tsx'], exclude: ['node_modules', 'dist'], }), + { + name: 'copy-style-css', + closeBundle() { + const src = resolve(__dirname, 'src/style.css'); + const dest = resolve(__dirname, 'dist/style.css'); + copyFileSync(src, dest); + }, + }, ], build: { lib: { diff --git a/pyproject.toml b/pyproject.toml index e37abb8..41f371e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,15 @@ include = ["lc_conductor*"] [project.optional-dependencies] autogen = ["autogen-agentchat>=0.7.0", "autogen-core>=0.7.0", "autogen-ext>=0.7.0", "openai>=2.0.0", "tiktoken>=0.10.0", "autogen-ext[openai]"] -test = ["pytest", "pytest-asyncio", "pytest-mock", "responses", "httpx", "requests-mock"] +test = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-mock>=3.10.0", + "pytest-cov>=4.0.0", + "responses>=0.22.0", + "httpx>=0.24.0", + "requests-mock>=1.11.0", + "coverage[toml]>=7.0.0" +] -all = ["lc-conductor[autogen]"] +all = ["lc-conductor[autogen,test]"] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..40c9ad9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,118 @@ +# LC-Conductor Test Suite + +Comprehensive pytest-based test suite for the curl command infrastructure. + +## Quick Start + +```bash +# Install test dependencies +pip install -e ".[test]" + +# Run all tests +pytest + +# Run unit tests only (fast) +pytest -m "not integration" + +# Run with coverage +pytest --cov=lc_conductor +``` + +## Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures and configuration +├── test_curl_parser.py # Parser validation tests (80+ tests) +├── test_curl_executor.py # Executor and handler tests (30+ tests) +└── test_curl_integration.py # Integration tests (20+ tests) +``` + +## Running Tests + +### Basic Commands + +```bash +# All tests +pytest + +# Specific file +pytest tests/test_curl_parser.py + +# Specific test +pytest tests/test_curl_parser.py::TestValidCurlCommands::test_simple_get_request + +# Pattern matching +pytest -k "test_get" +``` + +### By Category + +```bash +# Unit tests only (fast, no internet required) +pytest -m "not integration" + +# Integration tests (requires internet) +pytest -m integration +``` + +### With Coverage + +```bash +# Coverage report in terminal +pytest --cov=lc_conductor --cov-report=term-missing + +# HTML coverage report +pytest --cov-report=html +open htmlcov/index.html +``` + +## Test Categories + +### Parser Tests (`test_curl_parser.py`) +- Valid command parsing (GET, POST, PUT, DELETE, etc.) +- Security validations (protocol restrictions, shell metacharacters) +- Edge cases and error handling + +### Executor Tests (`test_curl_executor.py`) +- Command execution with mocked HTTP requests +- Error handling (timeouts, connection errors) +- Response processing and formatting + +### Integration Tests (`test_curl_integration.py`) +- Real HTTP requests to httpbin.org +- End-to-end workflows +- Performance testing + +## Writing Tests + +### Example Unit Test + +```python +def test_simple_get_request(valid_curl_commands): + """Test parsing a simple GET request.""" + cmd_data = valid_curl_commands["simple_get"] + result = parse_curl_command(cmd_data["command"]) + + assert result["method"] == "GET" + assert result["url"] == cmd_data["expected"]["url"] +``` + +### Example Async Test + +```python +@pytest.mark.asyncio +async def test_successful_execution(mocker): + """Test successful command execution.""" + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.return_value = Mock(status_code=200, text="OK") + + result = await execute_curl_command("curl https://example.com", "test") + assert result["success"] is True +``` + +## Coverage Goals + +- **Overall**: >90% +- **curl_parser.py**: >95% (critical security) +- **curl_executor.py**: >90% diff --git a/tests/test_curl_executor.py b/tests/test_curl_executor.py new file mode 100644 index 0000000..eb159b7 --- /dev/null +++ b/tests/test_curl_executor.py @@ -0,0 +1,290 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +""" +Tests for curl_executor module. + +Tests cover: +- Successful command execution +- Error handling (timeout, connection errors, parser errors) +- Request library integration +""" + +import pytest +import requests +from unittest.mock import Mock +from lc_conductor.curl_executor import execute_curl_command + + +@pytest.mark.asyncio +class TestExecuteCurlCommand: + """Test the execute_curl_command function.""" + + async def test_successful_get_request(self, mocker, mock_successful_response): + """Test successful GET request execution.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = "curl https://api.example.com/users" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert result["status_code"] == 200 + assert result["body"] == '{"message": "success"}' + assert "Content-Type" in result["headers"] + assert result["execution_time_ms"] > 0 + assert "timestamp" in result + + # Verify requests.request was called correctly + mock_requests.request.assert_called_once() + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["method"] == "GET" + assert call_kwargs["url"] == "https://api.example.com/users" + assert call_kwargs["timeout"] == 30.0 + assert call_kwargs["allow_redirects"] is True + + async def test_successful_post_request(self, mocker, mock_successful_response): + """Test successful POST request with data.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = 'curl -X POST https://api.example.com/users -d \'{"name": "test"}\'' + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert result["status_code"] == 200 + + # Verify POST data was sent + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["method"] == "POST" + assert call_kwargs["data"] == '{"name": "test"}' + + async def test_request_with_headers(self, mocker, mock_successful_response): + """Test request with custom headers.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = 'curl -H "Authorization: Bearer token123" https://api.example.com/data' + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + + # Verify headers were sent + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["headers"]["Authorization"] == "Bearer token123" + + async def test_timeout_error(self, mocker): + """Test handling of request timeout.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.exceptions.Timeout = requests.exceptions.Timeout + mock_requests.request.side_effect = requests.exceptions.Timeout( + "Timeout occurred" + ) + + cmd = "curl https://slow.example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is False + assert "timed out" in result["error"] + assert "30 seconds" in result["error"] + assert "timestamp" in result + assert result["execution_time_ms"] > 0 + + async def test_connection_error(self, mocker): + """Test handling of connection error.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.exceptions.Timeout = requests.exceptions.Timeout + mock_requests.exceptions.ConnectionError = requests.exceptions.ConnectionError + mock_requests.request.side_effect = requests.exceptions.ConnectionError( + "Connection refused" + ) + + cmd = "curl https://unreachable.example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is False + assert "Connection failed" in result["error"] + assert "Connection refused" in result["error"] + + async def test_parser_validation_error(self): + """Test handling of invalid curl command (parser error).""" + cmd = "curl file:///tmp/test.txt" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is False + assert "Invalid command" in result["error"] + assert "Only http/https protocols allowed" in result["error"] + + async def test_generic_exception(self, mocker): + """Test handling of unexpected exceptions.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.exceptions.Timeout = requests.exceptions.Timeout + mock_requests.exceptions.ConnectionError = requests.exceptions.ConnectionError + mock_requests.request.side_effect = Exception("Unexpected error") + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is False + assert "Execution failed" in result["error"] + assert "Unexpected error" in result["error"] + + async def test_response_headers_included(self, mocker): + """Test that response headers are included in result.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "body" + mock_response.headers = { + "Content-Type": "application/json", + "X-Custom-Header": "value", + } + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert "Content-Type" in result["headers"] + assert result["headers"]["Content-Type"] == "application/json" + assert result["headers"]["X-Custom-Header"] == "value" + + async def test_empty_response_body(self, mocker): + """Test handling of empty response body.""" + mock_response = Mock() + mock_response.status_code = 204 + mock_response.text = "" + mock_response.headers = {} + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert result["status_code"] == 204 + assert result["body"] == "" + + async def test_large_response_body(self, mocker): + """Test handling of large response bodies.""" + large_body = "x" * 100000 + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = large_body + mock_response.headers = {} + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert len(result["body"]) == 100000 + + async def test_execution_time_tracking(self, mocker, mock_successful_response): + """Test that execution time is tracked.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert "execution_time_ms" in result + assert isinstance(result["execution_time_ms"], float) + assert result["execution_time_ms"] >= 0 + + async def test_timestamp_format(self, mocker, mock_successful_response): + """Test that timestamp is in ISO format.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test-client") + + assert "timestamp" in result + assert isinstance(result["timestamp"], str) + assert result["timestamp"].endswith("Z") # UTC indicator + + async def test_allow_redirects_enabled(self, mocker, mock_successful_response): + """Test that redirect following is enabled.""" + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + cmd = "curl https://example.com" + await execute_curl_command(cmd, "test-client") + + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["allow_redirects"] is True + + +@pytest.mark.asyncio +class TestIntegrationScenarios: + """Integration-style tests covering end-to-end scenarios.""" + + async def test_complete_post_workflow(self, mocker): + """Test complete POST request workflow.""" + mock_response = Mock() + mock_response.status_code = 201 + mock_response.text = '{"id": 123, "name": "created"}' + mock_response.headers = {"Location": "/users/123"} + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = 'curl -X POST https://api.example.com/users -H "Content-Type: application/json" -d \'{"name": "test"}\'' + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + assert result["status_code"] == 201 + assert "id" in result["body"] + assert result["headers"]["Location"] == "/users/123" + + # Verify full request details + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["method"] == "POST" + assert call_kwargs["headers"]["Content-Type"] == "application/json" + assert '{"name": "test"}' in call_kwargs["data"] + + async def test_authentication_workflow(self, mocker): + """Test request with authentication header.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = '{"authenticated": true}' + mock_response.headers = {} + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = 'curl -H "Authorization: Bearer secret-token" https://api.example.com/protected' + result = await execute_curl_command(cmd, "test-client") + + assert result["success"] is True + + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["headers"]["Authorization"] == "Bearer secret-token" + + async def test_error_response_handling(self, mocker): + """Test handling of HTTP error responses (4xx, 5xx).""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.text = '{"error": "Not found"}' + mock_response.headers = {} + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_response + + cmd = "curl https://api.example.com/notfound" + result = await execute_curl_command(cmd, "test-client") + + # Should still succeed (request completed), but with error status code + assert result["success"] is True + assert result["status_code"] == 404 + assert "Not found" in result["body"] diff --git a/tests/test_curl_integration.py b/tests/test_curl_integration.py new file mode 100644 index 0000000..84701bb --- /dev/null +++ b/tests/test_curl_integration.py @@ -0,0 +1,289 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +""" +Integration tests for curl command infrastructure. + +These tests verify the full stack from command parsing through execution. +They use real HTTP requests to test services like httpbin.org. +""" + +import pytest +from lc_conductor.curl_executor import execute_curl_command + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestRealHTTPRequests: + """ + Integration tests using real HTTP requests. + + Note: These tests require internet connectivity and may be slow. + Mark with @pytest.mark.integration to allow selective execution. + Run with: pytest -m integration + """ + + async def test_httpbin_get(self): + """Test GET request to httpbin.org.""" + cmd = "curl https://httpbin.org/get" + result = await execute_curl_command(cmd, "integration-test") + + assert result["success"] is True + assert result["status_code"] == 200 + assert "httpbin.org" in result["body"] + + async def test_httpbin_post(self): + """Test POST request to httpbin.org.""" + cmd = 'curl -X POST https://httpbin.org/post -d \'{"test": "data"}\'' + result = await execute_curl_command(cmd, "integration-test") + + assert result["success"] is True + assert result["status_code"] == 200 + assert "test" in result["body"] + + async def test_httpbin_headers(self): + """Test request with custom headers.""" + cmd = 'curl -H "X-Custom-Header: test-value" https://httpbin.org/headers' + result = await execute_curl_command(cmd, "integration-test") + + assert result["success"] is True + assert result["status_code"] == 200 + assert "X-Custom-Header" in result["body"] + + async def test_httpbin_status_codes(self): + """Test handling of different status codes.""" + # Test 404 + cmd = "curl https://httpbin.org/status/404" + result = await execute_curl_command(cmd, "integration-test") + + assert result["success"] is True + assert result["status_code"] == 404 + + +@pytest.mark.asyncio +class TestEndToEndScenarios: + """End-to-end scenarios using mocked responses.""" + + async def test_full_crud_workflow(self, mocker): + """Test complete CRUD workflow.""" + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + + # CREATE (POST) + create_response = mocker.Mock() + create_response.status_code = 201 + create_response.text = '{"id": 1, "name": "Item 1"}' + create_response.headers = {"Location": "/items/1"} + + mock_requests.request.return_value = create_response + + cmd = 'curl -X POST https://api.example.com/items -d \'{"name": "Item 1"}\'' + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + assert result["status_code"] == 201 + + # READ (GET) + read_response = mocker.Mock() + read_response.status_code = 200 + read_response.text = '{"id": 1, "name": "Item 1"}' + read_response.headers = {} + + mock_requests.request.return_value = read_response + + cmd = "curl https://api.example.com/items/1" + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + assert result["status_code"] == 200 + + # UPDATE (PUT) + update_response = mocker.Mock() + update_response.status_code = 200 + update_response.text = '{"id": 1, "name": "Updated Item"}' + update_response.headers = {} + + mock_requests.request.return_value = update_response + + cmd = 'curl -X PUT https://api.example.com/items/1 -d \'{"name": "Updated Item"}\'' + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + assert result["status_code"] == 200 + + # DELETE + delete_response = mocker.Mock() + delete_response.status_code = 204 + delete_response.text = "" + delete_response.headers = {} + + mock_requests.request.return_value = delete_response + + cmd = "curl -X DELETE https://api.example.com/items/1" + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + assert result["status_code"] == 204 + + async def test_retry_scenario(self, mocker): + """Test scenario where first request fails, second succeeds.""" + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + + # First attempt: timeout + mock_requests.request.side_effect = [ + TimeoutError("Connection timeout"), + mocker.Mock( + status_code=200, + text='{"data": "success"}', + headers={} + ) + ] + + # First attempt fails + cmd = "curl https://api.example.com/data" + result1 = await execute_curl_command(cmd, "test") + assert result1["success"] is False + + # Second attempt succeeds + result2 = await execute_curl_command(cmd, "test") + assert result2["success"] is True + assert result2["status_code"] == 200 + + async def test_multiple_commands_sequence(self, mocker): + """Test executing multiple different commands in sequence.""" + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + + commands = [ + ("curl https://api.example.com/users", 200, "GET"), + ("curl -X POST https://api.example.com/users -d '{}'", 201, "POST"), + ("curl -X PUT https://api.example.com/users/1 -d '{}'", 200, "PUT"), + ("curl -X DELETE https://api.example.com/users/1", 204, "DELETE"), + ] + + for cmd, expected_status, expected_method in commands: + mock_response = mocker.Mock() + mock_response.status_code = expected_status + mock_response.text = "{}" + mock_response.headers = {} + + mock_requests.request.return_value = mock_response + + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + assert result["status_code"] == expected_status + + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["method"] == expected_method + + +@pytest.mark.asyncio +class TestErrorRecovery: + """Test error recovery and edge case handling.""" + + async def test_partial_failure_handling(self, mocker): + """Test handling when parser succeeds but execution fails.""" + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.side_effect = ConnectionError("Network unreachable") + + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test") + + assert result["success"] is False + assert "Connection failed" in result["error"] + assert "execution_time_ms" in result + assert "timestamp" in result + + async def test_malformed_url_handling(self): + """Test handling of malformed URLs.""" + cmd = "curl https://not a valid url" + result = await execute_curl_command(cmd, "test") + + # Parser should catch this or requests will + assert result["success"] is False + assert "error" in result + + async def test_unicode_in_command(self, mocker): + """Test handling of unicode characters in command.""" + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.text = '{"message": "success"}' + mock_response.headers = {} + + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.return_value = mock_response + + cmd = 'curl -d \'{"name": "测试"}\' https://example.com' + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + + async def test_very_long_url(self, mocker): + """Test handling of very long URLs (but within limit).""" + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.text = "OK" + mock_response.headers = {} + + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.return_value = mock_response + + long_path = "/path" * 100 + cmd = f"curl https://example.com{long_path}" + result = await execute_curl_command(cmd, "test") + + assert result["success"] is True + + +@pytest.mark.asyncio +class TestPerformance: + """Performance-related tests.""" + + async def test_execution_time_reasonable(self, mocker): + """Test that execution time is tracked reasonably.""" + import time + + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.text = "OK" + mock_response.headers = {} + + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.return_value = mock_response + + start = time.time() + cmd = "curl https://example.com" + result = await execute_curl_command(cmd, "test") + end = time.time() + + actual_time_ms = (end - start) * 1000 + + # Execution time should be reasonable + assert result["execution_time_ms"] > 0 + assert result["execution_time_ms"] < actual_time_ms + 100 # Allow some overhead + + async def test_concurrent_executions(self, mocker): + """Test multiple concurrent command executions.""" + import asyncio + + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.text = "OK" + mock_response.headers = {} + + mock_requests = mocker.patch('lc_conductor.curl_executor.requests') + mock_requests.request.return_value = mock_response + + # Execute 5 commands concurrently + tasks = [ + execute_curl_command(f"curl https://example.com/endpoint{i}", "test") + for i in range(5) + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 5 + assert all(r["success"] for r in results) diff --git a/tests/test_curl_parser.py b/tests/test_curl_parser.py new file mode 100644 index 0000000..cec3cf7 --- /dev/null +++ b/tests/test_curl_parser.py @@ -0,0 +1,298 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +""" +Tests for curl_parser module. + +Tests cover: +- Valid curl command parsing +- Security validations (protocol, shell metacharacters) +- Edge cases (length limits, malformed input) +- Various HTTP methods and options +""" + +import pytest +from lc_conductor.curl_parser import parse_curl_command + + +class TestValidCurlCommands: + """Test parsing of valid curl commands.""" + + def test_simple_get_request(self, valid_curl_commands): + """Test parsing a simple GET request.""" + cmd_data = valid_curl_commands["simple_get"] + result = parse_curl_command(cmd_data["command"]) + + assert result["method"] == cmd_data["expected"]["method"] + assert result["url"] == cmd_data["expected"]["url"] + assert result["headers"] == cmd_data["expected"]["headers"] + assert result["data"] == cmd_data["expected"]["data"] + assert result["timeout"] == 30.0 + + def test_post_with_data(self, valid_curl_commands): + """Test parsing POST request with data.""" + cmd_data = valid_curl_commands["post_with_data"] + result = parse_curl_command(cmd_data["command"]) + + assert result["method"] == "POST" + assert result["url"] == cmd_data["expected"]["url"] + assert result["data"] == cmd_data["expected"]["data"] + + def test_with_headers(self, valid_curl_commands): + """Test parsing request with custom headers.""" + cmd_data = valid_curl_commands["with_headers"] + result = parse_curl_command(cmd_data["command"]) + + assert result["headers"] == cmd_data["expected"]["headers"] + assert result["url"] == cmd_data["expected"]["url"] + + def test_put_request(self, valid_curl_commands): + """Test parsing PUT request.""" + cmd_data = valid_curl_commands["put_with_data_and_headers"] + result = parse_curl_command(cmd_data["command"]) + + assert result["method"] == "PUT" + assert result["url"] == cmd_data["expected"]["url"] + assert result["data"] == cmd_data["expected"]["data"] + + def test_delete_request(self, valid_curl_commands): + """Test parsing DELETE request.""" + cmd_data = valid_curl_commands["delete_request"] + result = parse_curl_command(cmd_data["command"]) + + assert result["method"] == "DELETE" + assert result["url"] == cmd_data["expected"]["url"] + + def test_http_protocol(self): + """Test that HTTP (non-HTTPS) is allowed.""" + cmd = "curl http://example.com" + result = parse_curl_command(cmd) + + assert result["url"] == "http://example.com" + assert result["method"] == "GET" + + def test_https_protocol(self): + """Test that HTTPS is allowed.""" + cmd = "curl https://example.com" + result = parse_curl_command(cmd) + + assert result["url"] == "https://example.com" + + def test_long_flags(self): + """Test parsing with long flag formats (--request, --header, --data).""" + cmd = 'curl --request POST --header "Content-Type: application/json" --data \'{"key": "value"}\' https://api.example.com' + result = parse_curl_command(cmd) + + assert result["method"] == "POST" + assert result["headers"]["Content-Type"] == "application/json" + assert result["data"] == '{"key": "value"}' + + def test_data_raw_flag(self): + """Test parsing with --data-raw flag.""" + cmd = 'curl --data-raw \'raw data\' https://example.com' + result = parse_curl_command(cmd) + + assert result["data"] == "raw data" + + +class TestSecurityValidations: + """Test security validations that should reject malicious commands.""" + + def test_file_protocol_rejected(self, invalid_curl_commands): + """Test that file:// protocol is rejected.""" + cmd_data = invalid_curl_commands["file_protocol"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_ftp_protocol_rejected(self, invalid_curl_commands): + """Test that ftp:// protocol is rejected.""" + cmd_data = invalid_curl_commands["ftp_protocol"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_shell_semicolon_rejected(self, invalid_curl_commands): + """Test that semicolon (command chaining) is rejected.""" + cmd_data = invalid_curl_commands["shell_semicolon"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_shell_pipe_rejected(self, invalid_curl_commands): + """Test that pipe character is rejected.""" + cmd_data = invalid_curl_commands["shell_pipe"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_shell_ampersand_rejected(self, invalid_curl_commands): + """Test that ampersand (background execution) is rejected.""" + cmd_data = invalid_curl_commands["shell_ampersand"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_shell_backticks_rejected(self, invalid_curl_commands): + """Test that backticks (command substitution) are rejected.""" + cmd_data = invalid_curl_commands["shell_backticks"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_shell_dollar_rejected(self, invalid_curl_commands): + """Test that $() (command substitution) is rejected.""" + cmd_data = invalid_curl_commands["shell_dollar"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_all_dangerous_chars_rejected(self): + """Test that all dangerous shell metacharacters are rejected.""" + dangerous_chars = [';', '&', '|', '`', '$', '(', ')'] + + for char in dangerous_chars: + cmd = f"curl https://example.com{char}" + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd) + assert "unsafe shell characters" in str(exc_info.value) + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_missing_curl_prefix(self, invalid_curl_commands): + """Test that non-curl commands are rejected.""" + cmd_data = invalid_curl_commands["missing_curl_prefix"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_no_url_provided(self, invalid_curl_commands): + """Test that command without URL is rejected.""" + cmd_data = invalid_curl_commands["no_url"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_command_too_long(self, invalid_curl_commands): + """Test that very long commands are rejected.""" + cmd_data = invalid_curl_commands["too_long"] + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd_data["command"]) + + assert cmd_data["error_contains"] in str(exc_info.value) + + def test_empty_string(self): + """Test that empty string is rejected.""" + with pytest.raises(ValueError) as exc_info: + parse_curl_command("") + + assert "must start with 'curl'" in str(exc_info.value) + + def test_just_curl(self): + """Test that just 'curl' without arguments is rejected.""" + with pytest.raises(ValueError) as exc_info: + parse_curl_command("curl") + + assert "No URL found" in str(exc_info.value) + + def test_malformed_quotes(self): + """Test handling of malformed quotes.""" + cmd = 'curl https://example.com -H "Content-Type: application/json' + + with pytest.raises(ValueError) as exc_info: + parse_curl_command(cmd) + + assert "Invalid command syntax" in str(exc_info.value) + + def test_url_with_query_params(self): + """Test parsing URL with query parameters.""" + cmd = "curl https://api.example.com/users?page=1&limit=10" + result = parse_curl_command(cmd) + + assert result["url"] == "https://api.example.com/users?page=1&limit=10" + + def test_url_with_port(self): + """Test parsing URL with custom port.""" + cmd = "curl https://example.com:8080/api" + result = parse_curl_command(cmd) + + assert result["url"] == "https://example.com:8080/api" + + def test_multiple_headers(self): + """Test parsing multiple header flags.""" + cmd = 'curl -H "Accept: application/json" -H "User-Agent: test" -H "X-Custom: value" https://example.com' + result = parse_curl_command(cmd) + + assert len(result["headers"]) == 3 + assert result["headers"]["Accept"] == "application/json" + assert result["headers"]["User-Agent"] == "test" + assert result["headers"]["X-Custom"] == "value" + + def test_header_without_colon(self): + """Test that header without colon is handled gracefully.""" + cmd = 'curl -H "InvalidHeader" https://example.com' + result = parse_curl_command(cmd) + + # Should parse but header should not be added + assert result["url"] == "https://example.com" + + def test_whitespace_in_url(self): + """Test that URL is trimmed of whitespace.""" + cmd = "curl https://example.com " + result = parse_curl_command(cmd) + + assert result["url"] == "https://example.com" + + +class TestHTTPMethods: + """Test parsing various HTTP methods.""" + + @pytest.mark.parametrize("method", [ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS" + ]) + def test_http_method(self, method): + """Test parsing various HTTP methods.""" + cmd = f"curl -X {method} https://example.com" + result = parse_curl_command(cmd) + + assert result["method"] == method + assert result["url"] == "https://example.com" + + def test_lowercase_method(self): + """Test that lowercase method is converted to uppercase.""" + cmd = "curl -X post https://example.com" + result = parse_curl_command(cmd) + + assert result["method"] == "POST" + + def test_mixed_case_method(self): + """Test that mixed case method is converted to uppercase.""" + cmd = "curl -X PaTcH https://example.com" + result = parse_curl_command(cmd) + + assert result["method"] == "PATCH" diff --git a/tests/test_hpc_allocation.py b/tests/test_hpc_allocation.py new file mode 100644 index 0000000..0e77d95 --- /dev/null +++ b/tests/test_hpc_allocation.py @@ -0,0 +1,153 @@ +############################################################################### +## Copyright 2025-2026 Lawrence Livermore National Security, LLC. +## See the top-level LICENSE file for details. +## +## SPDX-License-Identifier: Apache-2.0 +############################################################################### + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from lc_conductor.hpc_allocation import ( + build_hpc_allocation_request, + execute_hpc_allocation_from_env, +) +from lc_conductor.backend_manager import ActionManager + + +def test_build_hpc_allocation_request_populates_fields(): + request_spec = build_hpc_allocation_request( + trigger_url="https://example.com/allocate", + trigger_token="secret", + ref="main", + system="lassen", + nodes=2, + time="01:00:00", + bank="mybank", + ) + + assert request_spec["method"] == "POST" + assert request_spec["url"] == "https://example.com/allocate" + assert ("token", "secret") in request_spec["data"] + assert ("ref", "main") in request_spec["data"] + assert ("variables[SYSTEM]", "lassen") in request_spec["data"] + assert ("variables[NODES]", "2") in request_spec["data"] + assert ("variables[TIME]", "01:00:00") in request_spec["data"] + assert ("variables[BANK]", "mybank") in request_spec["data"] + + +def test_build_hpc_allocation_request_requires_token(): + with pytest.raises(ValueError, match="Missing trigger token"): + build_hpc_allocation_request( + trigger_url="https://example.com/allocate", + trigger_token="", + ref="main", + system="lassen", + nodes=1, + time="01:00:00", + bank="mybank", + ) + + +@pytest.mark.asyncio +async def test_execute_hpc_allocation_from_env_missing_token(monkeypatch): + monkeypatch.delenv("FLASK_HPC_ALLOCATION_TOKEN", raising=False) + monkeypatch.delenv("GENESIS_RUNNER_TOKEN", raising=False) + monkeypatch.delenv("MY_PERSONAL_TOKEN", raising=False) + + executed_request, result = await execute_hpc_allocation_from_env( + system="lassen", + nodes=1, + time="01:00:00", + bank="mybank", + client_info="test-client", + ) + + assert executed_request is None + assert result["success"] is False + assert "Missing HPC allocation trigger token" in result["error"] + assert result["timestamp"].endswith("Z") + + +@pytest.mark.asyncio +async def test_execute_hpc_allocation_from_env_executes_request_via_requests( + monkeypatch, mocker, mock_successful_response +): + monkeypatch.setenv( + "FLASK_HPC_ALLOCATION_TRIGGER_URL", "https://example.com/allocate" + ) + monkeypatch.setenv("FLASK_HPC_ALLOCATION_TOKEN", "secret-token") + monkeypatch.setenv("FLASK_HPC_ALLOCATION_REF", "main") + + mock_requests = mocker.patch("lc_conductor.curl_executor.requests") + mock_requests.request.return_value = mock_successful_response + + executed_request, result = await execute_hpc_allocation_from_env( + system="lassen", + nodes=3, + time="02:00:00", + bank="wci", + client_info="test-client", + ) + + assert executed_request is not None + assert "POST https://example.com/allocate" in executed_request + assert "token=" in executed_request + assert result["success"] is True + assert result["status_code"] == 200 + mock_requests.request.assert_called_once() + call_kwargs = mock_requests.request.call_args[1] + assert call_kwargs["method"] == "POST" + assert ("variables[SYSTEM]", "lassen") in call_kwargs["data"] + assert ("variables[NODES]", "3") in call_kwargs["data"] + assert ("variables[TIME]", "02:00:00") in call_kwargs["data"] + assert ("variables[BANK]", "wci") in call_kwargs["data"] + + +@pytest.mark.asyncio +async def test_action_manager_handle_allocate_hpc_resources_sends_result(mocker): + websocket = AsyncMock() + task_manager = SimpleNamespace(websocket=websocket) + action_manager = ActionManager( + task_manager=task_manager, experiment=mocker.Mock(), args=None, username="u" + ) + + mocker.patch( + "lc_conductor.backend_manager.execute_hpc_allocation_from_env", + return_value=( + "POST https://example.com/allocate form: ref=main", + { + "success": True, + "status_code": 200, + "headers": {}, + "body": "ok", + "timestamp": "tZ", + }, + ), + ) + + await action_manager.handle_allocate_hpc_resources( + { + "type": "allocate-hpc-resources", + "requestId": "req-1", + "allocation": { + "system": "lassen", + "nodes": 1, + "time": "01:00:00", + "bank": "mybank", + }, + } + ) + + websocket.send_json.assert_awaited() + payload = websocket.send_json.call_args[0][0] + assert payload["type"] == "allocate-hpc-resources-result" + assert payload["requestId"] == "req-1" + assert payload["allocation"]["system"] == "lassen" + assert payload["allocation"]["nodes"] == 1 + assert payload["executedRequest"].startswith("POST https://example.com/allocate") + assert payload["result"]["success"] is True