diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9d855d3..b953ebd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vqlforge" -version = "0.1.0" +version = "0.1.5" description = "VQLForge Backend" readme = "README.md" requires-python = ">=3.12" @@ -10,10 +10,18 @@ dependencies = [ "fastapi[standard]>=0.115.12", "pydantic-ai>=0.1.3", "sqlalchemy>=2.0.40", + "structlog>=24.1.0", "uvicorn[standard]>=0.34.0", "sqlglot @ file:///app/sqlglot-vql" ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-fastapi-client>=0.6.0", + "pytest-mock>=3.12.0", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/backend/src/api/vdb_list.py b/backend/src/api/vdb_list.py index 38245fa..bf803e1 100644 --- a/backend/src/api/vdb_list.py +++ b/backend/src/api/vdb_list.py @@ -1,23 +1,73 @@ import logging from fastapi import APIRouter, HTTPException -from src.schemas.common import VDBResponse +from src.schemas.common import VDBResponse, VDBConfigFile, VDBResponseItem from src.config import settings import os import yaml from typing import List, Dict, Any router = APIRouter() +logger = logging.getLogger(__name__) -def load_config_values() -> dict[list[str]]: - logging.info(f"Loading configuration from {settings.APP_VDB_CONF}") - with open(settings.APP_VDB_CONF, 'r') as f: - raw_config = yaml.safe_load(f) # Use safe_load for security - return raw_config + +def load_vdb_config_from_file() -> VDBConfigFile: + """ + Loads and parses the VDB configuration from the YAML file. + + Returns: + VDBConfigFile: A Pydantic model representing the loaded configuration. + + Raises: + HTTPException: If the file is not found, cannot be parsed, or is invalid. + """ + config_path = settings.APP_VDB_CONF + logger.info(f"Attempting to load VDB configuration from: {config_path}") + + if not os.path.exists(config_path): + logger.error(f"VDB configuration file not found at: {config_path}") + raise HTTPException( + status_code=500, + detail=f"Server configuration error: VDB config file not found at {config_path}" + ) + if not os.path.isfile(config_path): + logger.error(f"VDB configuration path is not a file: {config_path}") + raise HTTPException( + status_code=500, + detail=f"Server configuration error: VDB config path is not a file at {config_path}" + ) + + try: + with open(config_path, 'r') as f: + raw_config = yaml.safe_load(f) + # Validate the loaded YAML against the Pydantic model + vdb_config = VDBConfigFile.model_validate(raw_config) + logger.info("Successfully loaded and validated VDB configuration.") + return vdb_config + except yaml.YAMLError as e: + logger.error(f"Error parsing vdb_conf.yaml: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Server configuration error: Invalid YAML format in {config_path}. Details: {e}" + ) + except Exception as e: + logger.error(f"Unexpected error loading VDB config: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Server configuration error: Failed to load VDB config from {config_path}. Details: {e}" + ) -def transform_values(string_list: List[str]) -> List[Dict[str, str]]: - """Transforms a list of strings into a list of {'value': string, 'label': string}.""" - return [{"value": item, "label": item} for item in string_list] +def transform_vdb_strings_to_response_items(string_list: List[str]) -> List[VDBResponseItem]: + """ + Transforms a list of VDB names (strings) into a list of VDBResponseItem models. + + Args: + string_list: A list of strings, where each string is a VDB name. + + Returns: + A list of VDBResponseItem models, each with 'value' and 'label' set to the VDB name. + """ + return [VDBResponseItem(value=item, label=item) for item in string_list] @router.get("/vdbs", response_model=VDBResponse, tags=["VQL Forge"]) @@ -25,21 +75,38 @@ async def get_vdb_list() -> VDBResponse: """ Retrieves a list of VDBs from the configuration file. """ - if not settings.APP_VDB_CONF: - logging.error("No VDB CONFIG FILE") - raise HTTPException( - status_code=500, detail="VDB service error: config missing." - ) - logging.info( - f"Request received for /vdbs. Using config file: {os.path.abspath(settings.APP_VDB_CONF)}") - config = load_config_values() # Your config loading function + try: + # Load and validate the config using the Pydantic model + vdb_config = load_vdb_config_from_file() - if config['vdbs'] is None: - logging.warning("'vdbs' list empty in configuration. Returning empty list.") - return VDBResponse(results=[]) + # Transform the list of strings from the config into the desired response format + transformed_vdbs = transform_vdb_strings_to_response_items(vdb_config.vdbs) + + return VDBResponse(results=transformed_vdbs) + except HTTPException as http_exc: + # Re-raise HTTPExceptions as they are already formatted for FastAPI + raise http_exc + except Exception as e: + logger.error(f"Unhandled error in get_vdb_list: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="An unexpected error occurred while fetching VDB list.") + +@router.get("/vdbs", response_model=VDBResponse, tags=["VQL Forge"]) +async def get_vdb_list() -> VDBResponse: + """ + Retrieves a list of VDBs from the configuration file. + """ try: - return VDBResponse(results=transform_values(config['vdbs'])) + # Load and validate the config using the Pydantic model + vdb_config = load_vdb_config_from_file() + + # Transform the list of strings from the config into the desired response format + transformed_vdbs = transform_vdb_strings_to_response_items(vdb_config.vdbs) + + return VDBResponse(results=transformed_vdbs) + except HTTPException as http_exc: + # Re-raise HTTPExceptions as they are already formatted for FastAPI + raise http_exc except Exception as e: - logging.error(f"Error creating VDBResponse: {e}. Data was: {config}", exc_info=True) - raise HTTPException(status_code=500, detail="Error processing VDB list.") + logger.error(f"Unhandled error in get_vdb_list: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="An unexpected error occurred while fetching VDB list.") diff --git a/backend/src/config.py b/backend/src/config.py index 81d79f5..8fa3527 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): DENODO_USER: str DENODO_PW: str GEMINI_API_KEY: str + AI_MODEL_NAME: str DATABASE_URL: str | None = None # Will be constructed APP_VDB_CONF: str diff --git a/backend/src/main.py b/backend/src/main.py index 59788ba..079e126 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,14 +1,14 @@ # src/main.py import logging from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware from src.api.router import api_router # Import the main router from src.config import settings # To access settings if needed for app config from src.db.session import engine as db_engine, init_db_engine # To ensure DB is up +from src.utils.logging_config import setup_logging # Import the new logging setup -# Configure logging -logging.basicConfig(level=logging.INFO) # You can make level configurable via settings +# Configure logging using the new setup function +setup_logging() logger = logging.getLogger(__name__) # Initialize services @@ -25,21 +25,7 @@ version="1.0.0", ) -# --- CORS Configuration --- -origins = [ - "http://localhost:4999", - "http://127.0.0.1:4999", - # Add production frontend origins here -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) -# --- End CORS Configuration --- +# CORS is now handled by the NGINX reverse proxy. # Include the main API router app.include_router(api_router) @@ -50,6 +36,3 @@ @app.get("/", tags=["Default"]) def read_root(): return {"message": "Welcome to VQLForge Backend!"} - -# For UVicorn ASGI server: -# Run with: uv uvicorn src.main:app --reload diff --git a/backend/src/schemas/common.py b/backend/src/schemas/common.py index 95d6a89..f287bee 100644 --- a/backend/src/schemas/common.py +++ b/backend/src/schemas/common.py @@ -1,5 +1,5 @@ from typing import List, Dict, Any -from pydantic import BaseModel +from pydantic import BaseModel, Field class HealthCheck(BaseModel): @@ -16,5 +16,14 @@ class QueryResponse(BaseModel): message: str | None = None +class VDBConfigFile(BaseModel): + vdbs: List[str] = Field(default_factory=list) # Changed from List[VDBConfigItem] + + +class VDBResponseItem(BaseModel): + value: str + label: str + + class VDBResponse(BaseModel): - results: List[Dict[str, str]] + results: List[VDBResponseItem] diff --git a/backend/src/utils/ai_analyzer.py b/backend/src/utils/ai_analyzer.py index 145e603..b8d8167 100644 --- a/backend/src/utils/ai_analyzer.py +++ b/backend/src/utils/ai_analyzer.py @@ -15,19 +15,17 @@ def _initialize_ai_agent(system_prompt: str, output_type: Type, tools: list[Tool] = []) -> Agent: if not settings.GEMINI_API_KEY: - logger.error("GEMINI_API_KEY environment variable not set.") + logger.error("AI_API_KEY environment variable not set.") raise HTTPException( status_code=500, detail="AI service configuration error: API key missing." ) return Agent( - # Consider making model name a config variable - "gemini-2.5-flash-preview-04-17", # "gemini-1.5-flash-latest" might be more current + settings.AI_MODEL_NAME, system_prompt=system_prompt, output_type=output_type, deps_type=set[str], tools=tools - # llm_kwargs={"api_key": settings.GEMINI_API_KEY} # pydantic-ai typically handles GOOGLE_API_KEY env var directly ) diff --git a/backend/src/utils/denodo_client.py b/backend/src/utils/denodo_client.py index 6a7a2a8..70d3122 100644 --- a/backend/src/utils/denodo_client.py +++ b/backend/src/utils/denodo_client.py @@ -1,17 +1,26 @@ import logging from fastapi import HTTPException -from sqlalchemy import text +from sqlalchemy import Engine, text from src.db.session import get_engine # Use the centralized engine logger = logging.getLogger(__name__) def get_available_views_from_denodo(vdb_name: str | None = None) -> list[str]: - # This needs actual implementation to query Denodo's metadata. - # Example: "LIST VIEWS ALL" or query information_schema views - # For now, returning a placeholder - logger.warning("get_available_views_from_denodo is using placeholder data.") - return ["placeholder_view1", f"placeholder_view_in_{vdb_name}" if vdb_name else "placeholder_view2"] + engine: Engine = get_engine() + vql = "SELECT database_name, name FROM get_views()" + try: + with engine.connect() as connection: + result = connection.execute(text(vql)) + views: list[dict[str, str]] = [dict(row._mapping) for row in result] + logger.info(f"Successfully retrieved Denodo functions: {len(views)} functions found.") + return views + except Exception as e: + logger.error(f"Error executing VQL query '{vql}' to get views: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve views from Denodo: {str(e)}", + ) def get_denodo_functions_list() -> list[str]: diff --git a/docker-compose.yml b/docker-compose.yml index 3ae5a95..816876e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: - DENODO_PW=${DENODO_PW} # AI KEY - GEMINI_API_KEY=${AI_API_KEY} + - AI_MODEL_NAME=${AI_MODEL_NAME} - CONTAINER_BACKEND_PORT=${CONTAINER_BACKEND_PORT:-5000} # Files - APP_VDB_CONF=/opt/vdb_conf.yaml diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 869c1a1..26f29f8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,3 +1,4 @@ +# frontend/Dockerfile # ---- Stage 1: Build ---- # Use an official Node runtime as a parent image for the build stage FROM node:24-alpine AS builder @@ -30,9 +31,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Expose port 4999 for Nginx EXPOSE 4999 -# Add a wrapper script -COPY entrypoint-wrapper.sh /entrypoint-wrapper.sh -RUN chmod +x /entrypoint-wrapper.sh - -# Use the wrapper script as the command -CMD ["/entrypoint-wrapper.sh"] \ No newline at end of file +# The wrapper script is no longer needed. +# The original Nginx command is used directly. +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/public/index.html b/frontend/public/index.html index 107db8e..723cc3d 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -5,26 +5,20 @@ - - - - - - - + + + + VQLForge - +
- - \ No newline at end of file + diff --git a/frontend/src/App.js b/frontend/src/App.js index c566be6..c7ba835 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -2,34 +2,42 @@ import React, { useState, useCallback, useEffect } from 'react'; import { CssBaseline, AppBar, Toolbar, Typography, Container, Box, - Button, CircularProgress, Card, CardContent, CardHeader, Alert, + Button, CircularProgress, Alert, AlertTitle, IconButton, Stack, useTheme, Autocomplete, TextField, } from '@mui/material'; -import CloseIcon from '@mui/icons-material/Close'; import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; import VerifiedIcon from '@mui/icons-material/Verified'; -import WhatshotIcon from '@mui/icons-material/Whatshot'; -import CodeMirror from '@uiw/react-codemirror'; +import CloseIcon from '@mui/icons-material/Close'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { sql } from '@codemirror/lang-sql'; -import { oneDark } from '@codemirror/theme-one-dark'; import { purple, blueGrey } from '@mui/material/colors'; -// --- Import the new component --- -import AiErrorAnalysis from './AiErrorAnalysis'; // Assuming this component exists -import AiValidationErrorAnalysis from './AiValidationErrorAnalysis'; // Assuming this component exists +// Import API Service +import { fetchVdbs, translateSql, validateSql } from './apiService.js'; + +// Import Custom Components +import CodeEditor from './components/Editors/CodeEditor.js'; +import VqlForgeLogo from './Logo.js'; + +// --- Import Alert Components --- +import AiErrorAnalysis from './components/Alerts/AiErrorAnalysis.js'; +import AiValidationErrorAnalysis from './components/Alerts/AiValidationErrorAnalysis.js'; // --- Configuration --- const availableDialects = [{ value: 'athena', label: 'Athena' }, { value: 'bigquery', label: 'BigQuery' }, { value: 'clickhouse', label: 'ClickHouse' }, { value: 'databricks', label: 'Databricks' }, { value: 'doris', label: 'Doris' }, { value: 'drill', label: 'Drill' }, { value: 'druid', label: 'Druid' }, { value: 'duckdb', label: 'DuckDB' }, { value: 'dune', label: 'Dune' }, { value: 'hive', label: 'Hive' }, { value: 'materialize', label: 'Materialize' }, { value: 'mysql', label: 'MySQL' }, { value: 'oracle', label: 'Oracle' }, { value: 'postgres', label: 'PostgreSQL' }, { value: 'presto', label: 'Presto' }, { value: 'prql', label: 'PRQL' }, { value: 'redshift', label: 'Redshift' }, { value: 'risingwave', label: 'RisingWave' }, { value: 'snowflake', label: 'Snowflake' }, { value: 'spark', label: 'Spark SQL' }, { value: 'spark2', label: 'Spark SQL 2' }, { value: 'sqlite', label: 'SQLite' }, { value: 'starrocks', label: 'StarRocks' }, { value: 'tableau', label: 'Tableau' }, { value: 'teradata', label: 'Teradata' }, { value: 'trino', label: 'Trino' }]; const editorExtensions = [sql()]; const initialTargetSqlPlaceholder = '-- Target SQL will appear here after conversion...'; +const conversionErrorPlaceholder = '-- Conversion Error --'; function App() { const theme = useTheme(); - const API_BASE_URL = ''; const [sourceDialect, setSourceDialect] = useState(availableDialects[0]); + // --- VDB State --- const [actualAvailableVDBs, setActualAvailableVDBs] = useState([]); - const [selectedVDB, setSelectedVDB] = useState(null); // Initialize to null + const [selectedVDB, setSelectedVDB] = useState(null); const [vdbsLoading, setVdbsLoading] = useState(false); const [vdbsError, setVdbsError] = useState(null); @@ -40,6 +48,8 @@ function App() { const [isValidating, setIsValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); + const anyLoading = isLoading || isValidating || vdbsLoading; + const clearValidationState = () => { setValidationResult(null); }; const clearErrorState = () => { setError(null); }; @@ -47,20 +57,10 @@ function App() { useEffect(() => { setVdbsLoading(true); setVdbsError(null); - fetch(`${API_BASE_URL}/api/vdbs`) - .then(response => { - if (!response.ok) { - return response.text().then(text => { throw new Error(`Failed to fetch VDBs: ${response.status} ${response.statusText} - ${text}`); }); - } - return response.json(); - }) + fetchVdbs() .then(data => { if (data && Array.isArray(data.results)) { setActualAvailableVDBs(data.results); - // Optionally set a default selected VDB if list is not empty and none is selected - // if (data.results.length > 0 && !selectedVDB) { - // setSelectedVDB(data.results[0]); - // } } else { console.error("VDB data from API is not in the expected format:", data); throw new Error("VDB data is not in the expected format (missing 'results' array)."); @@ -70,10 +70,10 @@ function App() { .catch(err => { console.error("Error fetching VDBs:", err); setVdbsError(err.message || "Could not fetch VDB options."); - setActualAvailableVDBs([]); // Set to empty array on error to avoid issues with Autocomplete + setActualAvailableVDBs([{ value: 'admin', label: 'Admin' }]); setVdbsLoading(false); }); - }, [API_BASE_URL]); // Removed selectedVDB from dependency to avoid re-fetch on selection + }, []); const onSourceChange = useCallback((value) => { setSourceSql(value); @@ -121,34 +121,10 @@ function App() { const requestBody = { sql: sourceSql, dialect: sourceDialect.value, - vdb: selectedVDB.value // selectedVDB is an object, use its value property + vdb: selectedVDB.value }; - try { - const response = await fetch(`${API_BASE_URL}/api/translate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - if (!response.ok) { - let errorDetails = `Translation Request Failed: ${response.status}`; - try { - const errorData = await response.json(); - errorDetails = errorData.detail || errorData.message || JSON.stringify(errorData); - if (errorDetails && !errorDetails.toLowerCase().includes(response.status.toString())) { - errorDetails = `(${response.status}) ${errorDetails}`; - } - } catch (parseError) { - try { - const textError = await response.text(); - if (textError) errorDetails = `(${response.status}) ${textError}`; - } catch (readError) { /* ignore */ } - } - throw new Error(errorDetails); - } - - const data = await response.json(); + const data = await translateSql(requestBody); if (data && typeof data.vql === 'string') { setTargetSql(data.vql); @@ -166,7 +142,7 @@ function App() { } catch (err) { console.error("Conversion process failed:", err); setError(err.message || 'Unknown conversion error.'); - setTargetSql('-- Conversion Error --'); + setTargetSql(conversionErrorPlaceholder); } finally { setIsLoading(false); } @@ -180,7 +156,7 @@ function App() { if (validationResult?.status === 'error_ai') { return; } - if (!targetSql || targetSql === initialTargetSqlPlaceholder || targetSql === '-- Conversion Error --') { + if (!targetSql || targetSql === initialTargetSqlPlaceholder || targetSql === conversionErrorPlaceholder) { setValidationResult({ status: 'info', message: 'Convert the SQL to VQL first or resolve conversion errors.' }); return; } @@ -200,89 +176,93 @@ function App() { return; } - const validateRequestBody = { - sql: sourceSql, - vql: vqlWithoutLineBreaks - }; - try { - const validateResponse = await fetch(`${API_BASE_URL}/api/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify(validateRequestBody) - }); - - let validationData; - try { - validationData = await validateResponse.json(); - } catch (jsonError) { - const textError = await validateResponse.text(); - throw new Error(`(${validateResponse.status}) Server returned non-JSON response: ${textError || validateResponse.statusText}`); - } + const validationData = await validateSql(sourceSql, vqlWithoutLineBreaks); - if (!validateResponse.ok) { - if (validationData?.error_analysis) { - setValidationResult({ status: 'error_ai', data: validationData.error_analysis }); - } else { - const errorMessage = validationData?.message || validationData?.detail || `Validation Request Failed: ${validateResponse.status}`; - setValidationResult({ status: 'error', message: errorMessage }); - } + if (validationData.validated) { + setValidationResult({ + status: 'success', + message: validationData.message || `VQL syntax check successful!` + }); } else { - if (validationData.validated) { - setValidationResult({ status: 'success', message: validationData.message || `VQL syntax check successful!` }); + // Check if we have error_analysis data + if (validationData.error_analysis) { + // Set as AI error with the complete error_analysis object + setValidationResult({ + status: 'error_ai', + data: validationData.error_analysis // Pass the error_analysis object directly + }); } else { - if (validationData.error_analysis) { - setValidationResult({ status: 'error_ai', data: validationData.error_analysis }); - } else { - setValidationResult({ status: 'error', message: validationData.message || 'Validation Failed: Denodo rejected the query syntax/plan.' }); - } + // Fallback to regular error + setValidationResult({ + status: 'error', + message: validationData.message || 'Validation Failed: Denodo rejected the query syntax/plan.' + }); } } + } catch (err) { console.error("Validation process error:", err); - setValidationResult({ - status: 'error', - message: `Validation Process Error: ${err.message || 'Unknown error.'}` - }); + if (err.status === 'error_ai' && err.data) { + setValidationResult({ status: 'error_ai', data: err.data }); + } else { + setValidationResult({ + status: 'error', + message: `Validation Process Error: ${err.message || 'Unknown error.'}` + }); + } } finally { setIsValidating(false); } }; - const anyLoading = isLoading || isValidating || vdbsLoading; + const getValidationAlertProps = () => { + if (!validationResult) return null; - const targetEditorFixedWidth = '550px'; - const controlsFixedWidth = '220px'; - const renderEditorCard = (title, borderColor, value, readOnly = false, onChangeHandler = null) => ( - - - - - - - - - ); + const status = validationResult.status; + if (status === 'success') { + return { + severity: 'success', + icon: , + title: 'Validation Successful' + }; + } + if (status === 'info') { + return { + severity: 'info', + icon: , + title: 'Validation Info' + }; + } + if (status === 'error') { + return { + severity: 'error', + icon: , + title: 'Validation Error' + }; + } + return null; + }; - const alertCloseButton = (onCloseHandler) => (); + const validationAlertProps = getValidationAlertProps(); return ( - + - + - - - VQLForge + + + VQLForge + @@ -299,20 +279,18 @@ function App() { Error {error} )} - {/* --- VDB Loading/Error --- */} - {vdbsLoading && !vdbsError && ( // Show loading only if no error + {vdbsLoading && !vdbsError && ( }>Loading VDB options... )} {vdbsError && ( setVdbsError(null))} + onClose={() => setVdbsError(null)} > VDB Load Issue {vdbsError} - VDB selection might be unavailable or incomplete. @@ -325,22 +303,18 @@ function App() { onUseVqlSuggestion={handleUseVqlSuggestion} /> )} - {validationResult?.status === 'success' && ( - - Validation Successful - {validationResult.message} - - )} - {validationResult?.status === 'info' && ( - - Validation Info - {validationResult.message} - - )} - {/* Moved error type error (the one related to validation) down to avoid overlap with general error */} - {validationResult?.status === 'error' && ( - - Validation Error + {validationAlertProps && ( + + {validationAlertProps.title} {validationResult.message} )} @@ -354,10 +328,17 @@ function App() { sx={{ flexGrow: 1 }} > - {renderEditorCard(`Source (${sourceDialect ? sourceDialect.label : 'Select Dialect'})`, theme.palette.primary.main, sourceSql, false, onSourceChange)} + - + } + renderOption={(props, option) => ( + img': { mr: 2, flexShrink: 0 } }} {...props}> + {option.label} + + )} /> option.label || ""} value={selectedVDB} onChange={handleVDBChange} isOptionEqualToValue={(option, value) => option?.value === value?.value} - disabled={anyLoading || !!vdbsError || actualAvailableVDBs.length === 0} + disabled={anyLoading} fullWidth renderInput={(params) => ( {isLoading && ()} + {isValidating && ()} + - - {renderEditorCard("Target (VQL)", purple[500], targetSql, true)} + + - VQLForge 0.1 - + VQLForge 0.1.5 - MIT License diff --git a/frontend/src/Logo.js b/frontend/src/Logo.js new file mode 100644 index 0000000..fbb7591 --- /dev/null +++ b/frontend/src/Logo.js @@ -0,0 +1,28 @@ +import React from 'react'; + +function Logo() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +export default Logo; diff --git a/frontend/src/apiService.js b/frontend/src/apiService.js new file mode 100644 index 0000000..657f841 --- /dev/null +++ b/frontend/src/apiService.js @@ -0,0 +1,74 @@ +const API_BASE_URL = ''; // This should ideally come from environment variables + +export const fetchVdbs = async () => { + const response = await fetch(`${API_BASE_URL}/api/vdbs`); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to fetch VDBs: ${response.status} ${response.statusText} - ${text}`); + } + return response.json(); +}; + +export const translateSql = async (requestBody) => { + const response = await fetch(`${API_BASE_URL}/api/translate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + let errorDetails = `Translation Request Failed: ${response.status}`; + try { + const errorData = await response.json(); + // Prioritize FastAPI's 'detail' field, which is common for errors + errorDetails = errorData.detail || errorData.message || JSON.stringify(errorData); + } catch (parseError) { + // Fallback if the error response isn't valid JSON + try { + const textError = await response.text(); + if (textError) errorDetails = textError; + } catch (readError) { + errorDetails = response.statusText; // Last resort + } + } + // Always throw an Error object with a clear, readable string message + throw new Error(`(${response.status}) ${errorDetails}`); + } + + return response.json(); +}; + +export const validateSql = async (sql, vql) => { + const validateRequestBody = { + sql: sql, + vql: vql + }; + + const validateResponse = await fetch(`${API_BASE_URL}/api/validate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(validateRequestBody) + }); + + let validationData; + try { + validationData = await validateResponse.json(); + } catch (jsonError) { + const textError = await validateResponse.text(); + throw new Error(`(${validateResponse.status}) Server returned non-JSON response: ${textError || validateResponse.statusText}`); + } + + if (!validateResponse.ok) { + if (validationData?.error_analysis) { + const error = new Error("Validation failed with AI analysis"); + error.data = validationData.error_analysis; + error.status = 'error_ai'; + throw error; + } else { + const errorMessage = validationData?.message || validationData?.detail || `Validation Request Failed: ${validateResponse.status}`; + throw new Error(errorMessage); + } + } else { + return validationData; + } +}; \ No newline at end of file diff --git a/frontend/src/AiErrorAnalysis.js b/frontend/src/components/Alerts/AiErrorAnalysis.js similarity index 100% rename from frontend/src/AiErrorAnalysis.js rename to frontend/src/components/Alerts/AiErrorAnalysis.js diff --git a/frontend/src/AiValidationErrorAnalysis.js b/frontend/src/components/Alerts/AiValidationErrorAnalysis.js similarity index 53% rename from frontend/src/AiValidationErrorAnalysis.js rename to frontend/src/components/Alerts/AiValidationErrorAnalysis.js index b39f1fb..c14b219 100644 --- a/frontend/src/AiValidationErrorAnalysis.js +++ b/frontend/src/components/Alerts/AiValidationErrorAnalysis.js @@ -10,19 +10,29 @@ import { Stack, useTheme, Alert, + Collapse, + Divider, } from '@mui/material'; import { ContentCopy, CheckCircleOutline, WarningAmber, + ExpandMore, + ExpandLess, + Code, } from '@mui/icons-material'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; function AiValidationErrorAnalysis({ errorData, onDismiss, onUseVqlSuggestion }) { const theme = useTheme(); const [copied, setCopied] = useState(false); + const [showRawError, setShowRawError] = useState(false); + + // Handle the correct data structure from the endpoint + const explanation = errorData?.explanation || errorData?.error_analysis?.explanation || ''; + const vql_suggestion = errorData?.sql_suggestion || errorData?.error_analysis?.sql_suggestion || errorData?.vql_suggestion; + - const { explanation, sql_suggestion: vql_suggestion } = errorData; const handleUseVqlSuggestionClick = async () => { if (!vql_suggestion) return; @@ -30,7 +40,7 @@ function AiValidationErrorAnalysis({ errorData, onDismiss, onUseVqlSuggestion }) try { await navigator.clipboard.writeText(vql_suggestion); setCopied(true); - setTimeout(() => setCopied(false), 2000); // Reset copied state for button feedback + setTimeout(() => setCopied(false), 2000); } catch (err) { console.error('Failed to copy VQL suggestion to clipboard: ', err); } @@ -40,13 +50,25 @@ function AiValidationErrorAnalysis({ errorData, onDismiss, onUseVqlSuggestion }) } }; + const handleCopyOnly = async () => { + if (!vql_suggestion) return; + + try { + await navigator.clipboard.writeText(vql_suggestion); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + const handleDismiss = () => { if (onDismiss) { onDismiss(); } }; - const severity = 'warning'; + const severity = 'error'; const mainColor = theme.palette[severity].main; const darkColor = theme.palette[severity].dark; const lighterColor = theme.palette[severity].lighter || theme.palette.grey[50]; @@ -83,73 +105,121 @@ function AiValidationErrorAnalysis({ errorData, onDismiss, onUseVqlSuggestion }) lineHeight: 1.2 }} > - VQL Validation Analysis + Validation Error Analysis + {/* Error Explanation */} + + {explanation} + + + {/* VQL Suggestion */} {vql_suggestion && ( - - Suggested VQL Correction: + + + Suggested VQL Fix: - {vql_suggestion} + {vql_suggestion} - {/* This IconButton is for quick copy if user only wants to copy from code block, separate from "Use Suggestion" button */} { // Inline handler for simple copy from here - try { - await navigator.clipboard.writeText(vql_suggestion); - setCopied(true); // This will make the "Use Suggestion" button show "Copied" if clicked after this - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }} + onClick={handleCopyOnly} size="small" sx={{ position: 'absolute', top: theme.spacing(0.5), right: theme.spacing(0.5), color: copied ? theme.palette.success.main : theme.palette.action.active, + backgroundColor: theme.palette.background.paper, '&:hover': { backgroundColor: theme.palette.action.hover, } }} - aria-label="copy suggested vql from code block" + aria-label="copy suggested vql" > - {copied ? : } + {copied ? : } )} - - - {explanation} - - + {/* Raw Error Toggle - Only show if different from explanation */} + {explanation && ( + + + + + + + + {explanation} + + + + + + )} + + {/* Action Buttons */} - {/* Button */} {vql_suggestion && ( )} + {/* AI Disclaimer */} - VQLForge employs AI for advanced analysis and suggestions, but careful user validation of security, performance, and correctness is essential. + AI-powered analysis and suggestions require validation for security, performance, and correctness. @@ -207,10 +276,20 @@ function AiValidationErrorAnalysis({ errorData, onDismiss, onUseVqlSuggestion }) } AiValidationErrorAnalysis.propTypes = { - errorData: PropTypes.shape({ - explanation: PropTypes.string.isRequired, - sql_suggestion: PropTypes.string, - }).isRequired, + errorData: PropTypes.oneOfType([ + // Legacy format + PropTypes.shape({ + explanation: PropTypes.string.isRequired, + sql_suggestion: PropTypes.string, + }), + // New endpoint format + PropTypes.shape({ + error_analysis: PropTypes.shape({ + explanation: PropTypes.string.isRequired, + sql_suggestion: PropTypes.string, + }).isRequired, + }), + ]).isRequired, onDismiss: PropTypes.func.isRequired, onUseVqlSuggestion: PropTypes.func.isRequired, }; diff --git a/frontend/src/components/Editors/CodeEditor.js b/frontend/src/components/Editors/CodeEditor.js new file mode 100644 index 0000000..bdf11fa --- /dev/null +++ b/frontend/src/components/Editors/CodeEditor.js @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { Card, CardContent, CardHeader, Box } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import { lineNumbers, highlightActiveLineGutter } from '@codemirror/view'; +import { indentOnInput } from '@codemirror/language'; +import { bracketMatching, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; +// There doesn't appear to be a standard, actively maintained CodeMirror 6 extension for a minimap +// A custom implementation or a third-party library might be required. +function CodeEditor({ + title, + borderColor, + value, + readOnly = false, + onChange, + height = "100%", + minHeight = "55vh", + extensions = [], + loading = false // Added loading prop to disable interaction +}) { + const onCodeChange = useCallback((value) => { + if (onChange) { + onChange(value); + } + }, [onChange]); + + return ( + + {/* Icon goes here */}} + sx={{ borderBottom: '5px solid', borderColor: borderColor, flexShrink: 0, py: 1.5, px: 2 }} titleTypographyProps={{ variant: 'h6' }} /> + + + + + + + ); +} + +export default CodeEditor; \ No newline at end of file diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..b04d279 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,72 @@ +import { createTheme } from '@mui/material/styles'; + +// --- Color Palette --- +const primaryColor = '#6d48e8'; // A vibrant purple +const secondaryColor = '#3a8dff'; // A complementary blue +const backgroundColor = '#f4f6f8'; // A light, clean background grey +const paperColor = '#ffffff'; +const textColor = '#344054'; +const darkGrey = '#1e293b'; + +// --- Theme Definition --- +const theme = createTheme({ + palette: { + primary: { + main: primaryColor, + }, + secondary: { + main: secondaryColor, + }, + background: { + default: backgroundColor, + paper: paperColor, + }, + text: { + primary: textColor, + secondary: darkGrey, + }, + }, + typography: { + fontFamily: '"Inter", "Helvetica", "Arial", sans-serif', + h1: { fontSize: '2.5rem', fontWeight: 600 }, + h2: { fontSize: '2rem', fontWeight: 600 }, + h3: { fontSize: '1.75rem', fontWeight: 600 }, + h4: { fontSize: '1.5rem', fontWeight: 600 }, + h5: { fontSize: '1.25rem', fontWeight: 600 }, + h6: { fontSize: '1.1rem', fontWeight: 600 }, + body1: { + fontSize: '1rem', + }, + button: { + textTransform: 'none', + fontWeight: 600, + }, + }, + components: { + MuiCard: { + styleOverrides: { + root: { + borderRadius: '12px', + boxShadow: '0px 4px 20px rgba(0, 0, 0, 0.05)', + border: '1px solid #e0e0e0' + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + borderRadius: '8px', + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.1)', + }, + }, + }, + }, +}); + +export default theme; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4b42cb6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "vqlforge", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/readme.md b/readme.md index d215e02..3af5432 100644 --- a/readme.md +++ b/readme.md @@ -54,7 +54,8 @@ Migrating SQL to Denodo's VQL can be a time-consuming and error-prone process. V | `DENODO_DB` | Default Denodo Virtual DataBase (VDB) | AI Validation | `my_vdb` | | `DENODO_USER` | Denodo user with read/execute access to VDBs | AI Validation | `denodo_user` | | `DENODO_PW` | Password for the Denodo user | AI Validation | `password` | - | `AI_API_KEY` | Google Gemini API Key (e.g., Gemini 1.5 Flash) | AI Assistant | `YOUR_GEMINI_API_KEY` | + | `AI_API_KEY` | LLM API Key | AI Assistant | `YOUR_AI_API_KEY` | + | `AI_MODEL_NAME` | Model name (e.g., gemini-2.5-flash) | AI Assistant | `LLM_MODEL_NAME` | | `APP_NETWORK_NAME` | Docker network name for connecting to Denodo (if Denodo is also in Docker) | AI Validation | `denodo-lab-net` | | `HOST_PROJECT_PATH` | Absolute path to your local VQLForge repository directory. | Translation (VDBs) | `/path/to/your/VQLForge` | diff --git a/template.env b/template.env index dba42f3..b418191 100644 --- a/template.env +++ b/template.env @@ -9,7 +9,8 @@ DENODO_HOST= DENODO_DB= DENODO_USER= # This user should read/execute access to all VDBs DENODO_PW= -AI_API_KEY= +AI_API_KEY= +AI_MODEL_NAME= APP_NETWORK_NAME=denodo-lab-net HOST_PROJECT_PATH= CONTAINER_VDB_CONF_PATH=/app/vdb_conf.yaml diff --git a/vqlforge.png b/vqlforge.png old mode 100755 new mode 100644 index 278ce96..c61a39a Binary files a/vqlforge.png and b/vqlforge.png differ