diff --git a/docker/project-base-image/Dockerfile b/docker/project-base-image/Dockerfile new file mode 100644 index 00000000..9d8300e0 --- /dev/null +++ b/docker/project-base-image/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20 + +WORKDIR /app + +# Pre-install common frontend dependencies to speed up project startup +RUN npm install -g npm@latest vite@latest + +# Create a non-root user to run the app +RUN groupadd -r appuser && useradd -r -g appuser -m appuser +RUN chown -R appuser:appuser /app + +# Switch to non-root user for security +USER appuser + +EXPOSE 5173 + +# The actual project code will be mounted as a volume +# The CMD will be provided when running the container +CMD ["sh", "-c", "npm install --include=dev && npm run dev -- --host 0.0.0.0"] \ No newline at end of file diff --git a/frontend/src/app/api/runProject/route.ts b/frontend/src/app/api/runProject/route.ts index 2b9cac37..c87d8aa5 100644 --- a/frontend/src/app/api/runProject/route.ts +++ b/frontend/src/app/api/runProject/route.ts @@ -10,6 +10,9 @@ import { URL_PROTOCOL_PREFIX } from '@/utils/const'; const CONTAINER_STATE_FILE = path.join(process.cwd(), 'container-state.json'); const PORT_STATE_FILE = path.join(process.cwd(), 'port-state.json'); +// Base image name - this is the single image we'll use for all containers +const BASE_IMAGE_NAME = 'frontend-base-image'; + // In-memory container and port state let runningContainers = new Map< string, @@ -23,6 +26,15 @@ const processingRequests = new Set(); // State lock to prevent concurrent reads/writes to state files let isUpdatingState = false; +// Flag to track if base image has been built +let baseImageBuilt = false; + +// limit memory usage for a container +const memoryLimit = '400m'; + +// limit cpu usage for a container +const cpusLimit = 1; + /** * Initialize function, loads persisted state when service starts */ @@ -75,6 +87,9 @@ async function initializeState() { // Save cleaned-up state await saveState(); + // Check if base image exists + baseImageBuilt = await checkBaseImageExists(); + console.log( 'State initialization complete, cleaned up non-running containers and expired port allocations' ); @@ -180,6 +195,21 @@ function checkContainerRunning(containerId: string): Promise { }); } +/** + * Check if base image exists + */ +function checkBaseImageExists(): Promise { + return new Promise((resolve) => { + exec(`docker image inspect ${BASE_IMAGE_NAME}`, (err) => { + if (err) { + resolve(false); + } else { + resolve(true); + } + }); + }); +} + /** * Check if there's already a container running with the specified label */ @@ -203,27 +233,42 @@ async function checkExistingContainer( } /** - * Remove node_modules and lock files + * Build base image if it doesn't exist */ -async function removeNodeModulesAndLockFiles(directory: string) { - return new Promise((resolve, reject) => { - const removeCmd = `rm -rf "${path.join(directory, 'node_modules')}" \ - "${path.join(directory, 'yarn.lock')}" \ - "${path.join(directory, 'package-lock.json')}" \ - "${path.join(directory, 'pnpm-lock.yaml')}"`; - - console.log(`Cleaning up node_modules and lock files in: ${directory}`); - exec(removeCmd, { timeout: 30000 }, (err, stdout, stderr) => { - if (err) { - console.error('Error removing node_modules or lock files:', stderr); - // Don't block the process, continue even if cleanup fails - resolve(); - return; - } - console.log(`Cleanup done: ${stdout}`); - resolve(); - }); - }); +async function ensureBaseImageExists(): Promise { + if (baseImageBuilt) { + return; + } + + try { + // Path to the base image Dockerfile + const dockerfilePath = path.join( + process.cwd(), + '../docker', + 'project-base-image' + ); + + // Check if base Dockerfile exists + if (!fs.existsSync(path.join(dockerfilePath, 'Dockerfile'))) { + console.error('Base Dockerfile not found at:', dockerfilePath); + throw new Error('Base Dockerfile not found'); + } + + // Build the base image + console.log( + `Building base image ${BASE_IMAGE_NAME} from ${dockerfilePath}...` + ); + await execWithTimeout( + `docker build -t ${BASE_IMAGE_NAME} ${dockerfilePath}`, + { timeout: 300000, retries: 1 } // 5 minutes timeout, 1 retry + ); + + baseImageBuilt = true; + console.log(`Base image ${BASE_IMAGE_NAME} built successfully`); + } catch (error) { + console.error('Error building base image:', error); + throw new Error('Failed to build base image'); + } } /** @@ -265,9 +310,9 @@ function execWithTimeout( } /** - * Build and run Docker container + * Run Docker container using the base image */ -async function buildAndRunDocker( +async function runDockerContainer( projectPath: string ): Promise<{ domain: string; containerId: string; port: number }> { const traefikDomain = process.env.TRAEFIK_DOMAIN || 'docker.localhost'; @@ -307,25 +352,17 @@ async function buildAndRunDocker( } } + // Ensure base image exists + await ensureBaseImageExists(); + const directory = path.join(getProjectPath(projectPath), 'frontend'); const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); - const imageName = subdomain; const containerName = `container-${subdomain}`; const domain = `${subdomain}.${traefikDomain}`; // Allocate port const exposedPort = await findAvailablePort(); - // Remove node_modules and lock files - try { - await removeNodeModulesAndLockFiles(directory); - } catch (error) { - console.error( - 'Error during cleanup phase, but will continue with build:', - error - ); - } - try { // Check if a container with the same name already exists, remove it if found try { @@ -342,15 +379,6 @@ async function buildAndRunDocker( // If container doesn't exist, this will error out which is expected } - // Build Docker image - console.log( - `Starting Docker build for image: ${imageName} in directory: ${directory}` - ); - await execWithTimeout( - `docker build -t ${imageName} ${directory}`, - { timeout: 300000, retries: 1 } // 5 minutes timeout, 1 retry - ); - // Determine whether to use TLS or non-TLS configuration const TLS = process.env.TLS === 'true'; @@ -358,6 +386,8 @@ async function buildAndRunDocker( let runCommand; if (TLS) { runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ + --memory=${memoryLimit} --memory-swap=${memoryLimit} \ + --cpus=${cpusLimit} \ -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ -l "traefik.http.routers.${subdomain}.entrypoints=websecure" \ -l "traefik.http.routers.${subdomain}.tls=true" \ @@ -368,9 +398,11 @@ async function buildAndRunDocker( -l "traefik.http.routers.${subdomain}.middlewares=${subdomain}-cors" \ --network=docker_traefik_network -p ${exposedPort}:5173 \ -v "${directory}:/app" \ - ${imageName}`; + ${BASE_IMAGE_NAME}`; } else { runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ + --memory=${memoryLimit} --memory-swap=${memoryLimit} \ + --cpus=${cpusLimit} \ -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ -l "traefik.http.routers.${subdomain}.entrypoints=web" \ -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ @@ -380,7 +412,7 @@ async function buildAndRunDocker( -l "traefik.http.routers.${subdomain}.middlewares=${subdomain}-cors" \ --network=docker_traefik_network -p ${exposedPort}:5173 \ -v "${directory}:/app" \ - ${imageName}`; + ${BASE_IMAGE_NAME}`; } // Run container @@ -414,7 +446,7 @@ async function buildAndRunDocker( ); return { domain, containerId: containerActualId, port: exposedPort }; } catch (error: any) { - console.error(`Error building or running container:`, error); + console.error(`Error running container:`, error); // Clean up allocated port allocatedPorts.delete(exposedPort); @@ -499,7 +531,7 @@ export async function GET(req: Request) { // Prevent duplicate builds if (processingRequests.has(projectPath)) { return NextResponse.json({ - message: 'Build in progress', + message: 'Container creation in progress', status: 'pending', }); } @@ -507,7 +539,7 @@ export async function GET(req: Request) { processingRequests.add(projectPath); try { - const { domain, containerId } = await buildAndRunDocker(projectPath); + const { domain, containerId } = await runDockerContainer(projectPath); return NextResponse.json({ message: 'Docker container started', diff --git a/frontend/src/components/chat/code-engine/web-view.tsx b/frontend/src/components/chat/code-engine/web-view.tsx index d40f30c1..64a43979 100644 --- a/frontend/src/components/chat/code-engine/web-view.tsx +++ b/frontend/src/components/chat/code-engine/web-view.tsx @@ -25,11 +25,144 @@ function PreviewContent({ const [history, setHistory] = useState(['/']); const [currentIndex, setCurrentIndex] = useState(0); const [scale, setScale] = useState(0.7); + const [isServiceReady, setIsServiceReady] = useState(false); + const [serviceCheckAttempts, setServiceCheckAttempts] = useState(0); + const [loadingMessage, setLoadingMessage] = useState('Loading preview...'); + const [isLoading, setIsLoading] = useState(true); const iframeRef = useRef(null); const containerRef = useRef<{ projectPath: string; domain: string } | null>( null ); const lastProjectPathRef = useRef(null); + const serviceCheckTimerRef = useRef(null); + const MAX_CHECK_ATTEMPTS = 15; // Reduced max attempts since we have progressive intervals + + // Function to check if the frontend service is ready + const checkServiceReady = async (url: string) => { + try { + // Create a new AbortController instance + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds + + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-store', + signal: controller.signal, + headers: { 'Cache-Control': 'no-cache' }, + }); + + clearTimeout(timeoutId); + + // Service is ready if we get a successful response (not 404 or 5xx) + const isReady = + response.ok || (response.status !== 404 && response.status < 500); + console.log( + `Service check: ${url} - Status: ${response.status} - Ready: ${isReady}` + ); + return isReady; + } catch (error) { + // Don't log abort errors (expected when timeout occurs) + if (!error.toString().includes('abort')) { + console.log(`Service check attempt failed: ${error}`); + } + return false; + } + }; + + // Function to periodically check service readiness + const startServiceReadyCheck = async (url: string) => { + // Clear any existing timer + if (serviceCheckTimerRef.current) { + clearInterval(serviceCheckTimerRef.current); + } + + setServiceCheckAttempts(0); + setIsServiceReady(false); + setLoadingMessage('Loading preview...'); + + // Try immediately first (don't wait for interval) + const initialReady = await checkServiceReady(url); + if (initialReady) { + console.log('Frontend service is ready immediately!'); + setIsServiceReady(true); + return; // Exit early if service is ready immediately + } + + // Progressive check intervals (check more frequently at first) + const checkIntervals = [500, 1000, 1000, 1500, 1500]; // First few checks are faster + let checkIndex = 0; + + // Set a fallback timer - show preview after 45 seconds no matter what + const fallbackTimer = setTimeout(() => { + console.log('Fallback timer triggered - showing preview anyway'); + setIsServiceReady(true); + if (serviceCheckTimerRef.current) { + clearInterval(serviceCheckTimerRef.current); + serviceCheckTimerRef.current = null; + } + }, 45000); + + const runServiceCheck = async () => { + setServiceCheckAttempts((prev) => prev + 1); + + // Update loading message with attempts + if (serviceCheckAttempts > 3) { + setLoadingMessage( + `Starting frontend service... (${serviceCheckAttempts}/${MAX_CHECK_ATTEMPTS})` + ); + } + + const ready = await checkServiceReady(url); + + if (ready) { + console.log('Frontend service is ready!'); + setIsServiceReady(true); + clearTimeout(fallbackTimer); + if (serviceCheckTimerRef.current) { + clearInterval(serviceCheckTimerRef.current); + serviceCheckTimerRef.current = null; + } + } else if (serviceCheckAttempts >= MAX_CHECK_ATTEMPTS) { + // Service didn't become ready after max attempts + console.log( + 'Max attempts reached. Service might still be initializing.' + ); + setLoadingMessage( + 'Preview might not be fully loaded. Click refresh to try again.' + ); + + // Show the preview anyway after max attempts + setIsServiceReady(true); + clearTimeout(fallbackTimer); + + if (serviceCheckTimerRef.current) { + clearInterval(serviceCheckTimerRef.current); + serviceCheckTimerRef.current = null; + } + } else { + // Schedule next check with dynamic interval + const nextInterval = + checkIndex < checkIntervals.length + ? checkIntervals[checkIndex++] + : 2000; // Default to 2000ms after initial fast checks + + setTimeout(runServiceCheck, nextInterval); + } + }; + + // Start the first check + setTimeout(runServiceCheck, 500); + }; + + useEffect(() => { + // Cleanup interval on component unmount + return () => { + if (serviceCheckTimerRef.current) { + clearInterval(serviceCheckTimerRef.current); + serviceCheckTimerRef.current = null; + } + }; + }, []); useEffect(() => { const initWebUrl = async () => { @@ -42,8 +175,14 @@ function PreviewContent({ lastProjectPathRef.current = projectPath; + // Reset service ready state for new project + setIsServiceReady(false); + if (containerRef.current?.projectPath === projectPath) { - setBaseUrl(`${URL_PROTOCOL_PREFIX}://${containerRef.current.domain}`); + const url = `${URL_PROTOCOL_PREFIX}://${containerRef.current.domain}`; + setBaseUrl(url); + setDisplayPath('/'); + startServiceReadyCheck(url); return; } @@ -58,8 +197,12 @@ function PreviewContent({ console.log('baseUrl:', baseUrl); setBaseUrl(baseUrl); setDisplayPath('/'); + + // Start checking if the service is ready + startServiceReadyCheck(baseUrl); } catch (error) { console.error('Error getting web URL:', error); + setLoadingMessage('Error initializing preview.'); } }; @@ -67,11 +210,11 @@ function PreviewContent({ }, [curProject, getWebUrl]); useEffect(() => { - if (iframeRef.current && baseUrl) { + if (iframeRef.current && baseUrl && isServiceReady) { const fullUrl = `${baseUrl}${displayPath}`; iframeRef.current.src = fullUrl; } - }, [baseUrl, displayPath]); + }, [baseUrl, displayPath, isServiceReady]); const enterFullScreen = () => { if (iframeRef.current) { @@ -112,6 +255,12 @@ function PreviewContent({ }; const reloadIframe = () => { + // Reset service ready check when manually reloading + if (baseUrl) { + setIsServiceReady(false); + startServiceReadyCheck(baseUrl); + } + const iframe = document.getElementById('myIframe') as HTMLIFrameElement; if (iframe) { const src = iframe.src; @@ -131,10 +280,6 @@ function PreviewContent({ setScale((prevScale) => Math.max(prevScale - 0.1, 0.5)); // 最小缩放比例为 0.5 }; - // print all stat - console.log('baseUrl outside:', baseUrl); - console.log('current project: ', curProject); - return (
{/* URL Bar */} @@ -146,7 +291,7 @@ function PreviewContent({ size="icon" className="h-6 w-6" onClick={goBack} - disabled={!baseUrl || currentIndex === 0} + disabled={!baseUrl || currentIndex === 0 || !isServiceReady} > @@ -155,7 +300,9 @@ function PreviewContent({ size="icon" className="h-6 w-6" onClick={goForward} - disabled={!baseUrl || currentIndex >= history.length - 1} + disabled={ + !baseUrl || currentIndex >= history.length - 1 || !isServiceReady + } > @@ -164,6 +311,7 @@ function PreviewContent({ size="icon" className="h-6 w-6" onClick={reloadIframe} + disabled={!baseUrl} > @@ -177,7 +325,7 @@ function PreviewContent({ onChange={(e) => handlePathChange(e.target.value)} className="h-8 bg-secondary" placeholder="/" - disabled={!baseUrl} + disabled={!baseUrl || !isServiceReady} />
@@ -188,7 +336,7 @@ function PreviewContent({ size="icon" onClick={zoomOut} className="h-8 w-8" - disabled={!baseUrl} + disabled={!baseUrl || !isServiceReady} > @@ -197,7 +345,7 @@ function PreviewContent({ size="icon" onClick={zoomIn} className="h-8 w-8" - disabled={!baseUrl} + disabled={!baseUrl || !isServiceReady} > @@ -206,7 +354,7 @@ function PreviewContent({ size="icon" onClick={openInNewTab} className="h-8 w-8" - disabled={!baseUrl} + disabled={!baseUrl || !isServiceReady} > @@ -215,7 +363,7 @@ function PreviewContent({ size="icon" onClick={enterFullScreen} className="h-8 w-8" - disabled={!baseUrl} + disabled={!baseUrl || !isServiceReady} > @@ -224,7 +372,7 @@ function PreviewContent({ {/* Preview Container */}
- {baseUrl ? ( + {baseUrl && isServiceReady ? (