Skip to content

Commit

Permalink
feat(frontend): Code Engine preview, support preview project right now (
Browse files Browse the repository at this point in the history
#121)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
NarwhalChen and autofix-ci[bot] authored Feb 19, 2025
1 parent 6a5c749 commit 9a65a54
Show file tree
Hide file tree
Showing 11 changed files with 275 additions and 9 deletions.
Binary file modified backend/database.db
Binary file not shown.
4 changes: 0 additions & 4 deletions backend/src/chat/chat.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ export class Chat extends SystemBaseModel {
})
messages: Message[];

@Field(() => ID)
@Column()
projectId: string;

@ManyToOne(() => User, (user) => user.chats, {
onDelete: 'CASCADE',
nullable: false,
Expand Down
4 changes: 3 additions & 1 deletion backend/src/project/project.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ManyToMany,
JoinTable,
OneToMany,
RelationId,
} from 'typeorm';
import { User } from 'src/user/user.model';
import { ProjectPackages } from './project-packages.model';
Expand All @@ -30,7 +31,8 @@ export class Project extends SystemBaseModel {
projectPath: string;

@Field(() => ID)
@Column()
@RelationId((project: Project) => project.user)
@Column({ name: 'user_id' })
userId: string;

@ManyToOne(() => User, (user) => user.projects, {
Expand Down
15 changes: 15 additions & 0 deletions backend/template/react-ts/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM node:18

WORKDIR /app

COPY package*.json ./

RUN npm install --frozen-lockfile

COPY . .

RUN chmod +x /app/node_modules/.bin/vite

EXPOSE 5173

CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
14 changes: 14 additions & 0 deletions backend/template/react-ts/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,19 @@ export default defineConfig({
},
build: {
target: 'esnext', // Ensure Vite compiles for a modern target
sourcemap: false,
rollupOptions: {
output: {
manualChunks: undefined, // avoid sending code by chunk
},
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true,
},
},
});
25 changes: 25 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: '3'

services:
reverse-proxy:
# The official v3 Traefik docker image
image: traefik:v3.3
# Enables the web UI and tells Traefik to listen to docker
command:
- '--api.insecure=true'
- '--providers.docker'
- '--entrypoints.web.address=:80'
ports:
# The HTTP port
- '80:80'
# The Web UI (enabled by --api.insecure=true)
- '9001:8080'
volumes:
# So that Traefik can listen to the Docker events
- /var/run/docker.sock:/var/run/docker.sock
networks:
- traefik_network

networks:
traefik_network:
external: true
127 changes: 127 additions & 0 deletions frontend/src/app/api/runProject/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { NextResponse } from 'next/server';
import { exec } from 'child_process';
import * as path from 'path';
import * as crypto from 'crypto';
import * as net from 'net';
import { getProjectPath } from 'codefox-common';

const runningContainers = new Map<
string,
{ domain: string; containerId: string }
>();
const allocatedPorts = new Set<number>();

function findAvailablePort(
minPort: number = 38000,
maxPort: number = 42000
): Promise<number> {
return new Promise((resolve, reject) => {
function checkPort(port: number): Promise<boolean> {
return new Promise((resolve) => {
if (allocatedPorts.has(port)) {
return resolve(false);
}
const server = net.createServer();
server.listen(port, '127.0.0.1', () => {
server.close(() => resolve(true));
});
server.on('error', () => resolve(false));
});
}

async function scanPorts() {
for (let port = minPort; port <= maxPort; port++) {
if (await checkPort(port)) {
allocatedPorts.add(port);
return resolve(port);
}
}
reject(new Error('No available ports found.'));
}

scanPorts();
});
}

async function buildAndRunDocker(
projectPath: string
): Promise<{ domain: string; containerId: string }> {
console.log(runningContainers);
if (runningContainers.has(projectPath)) {
console.log(`Container for project ${projectPath} is already running.`);
return runningContainers.get(projectPath)!;
}
const traefikDomain = process.env.TRAEFIK_DOMAIN || 'docker.localhost';
const directory = path.join(getProjectPath(projectPath), 'frontend');
const imageName = projectPath.toLowerCase();
const containerId = crypto.randomUUID();
const containerName = `container-${containerId}`;

const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase();
const domain = `${subdomain}.${traefikDomain}`;
const exposedPort = await findAvailablePort();
return new Promise((resolve, reject) => {
exec(
`docker build -t ${imageName} ${directory}`,
(buildErr, buildStdout, buildStderr) => {
if (buildErr) {
console.error(`Error during Docker build: ${buildStderr}`);
return reject(buildErr);
}

console.log(`Docker build output:\n${buildStdout}`);
console.log(`Running Docker container: ${containerName}`);

const runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \
-l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \
-l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \
--network=traefik_network -p ${exposedPort}:5173 ${imageName}`;
console.log(runCommand);

exec(runCommand, (runErr, runStdout, runStderr) => {
if (runErr) {
console.error(`Error during Docker run: ${runStderr}`);
return reject(runErr);
}

const containerActualId = runStdout.trim();
runningContainers.set(projectPath, {
domain,
containerId: containerActualId,
});

console.log(
`Container ${containerName} is now running at http://${domain}`
);

resolve({ domain, containerId: containerActualId });
});
}
);
});
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const projectPath = searchParams.get('projectPath');

if (!projectPath) {
return NextResponse.json(
{ error: 'Missing required parameters' },
{ status: 400 }
);
}

try {
const { domain, containerId } = await buildAndRunDocker(projectPath);
return NextResponse.json({
message: 'Docker container started',
domain,
containerId,
});
} catch (error) {
return NextResponse.json(
{ error: error.message || 'Failed to start Docker container' },
{ status: 500 }
);
}
}
3 changes: 2 additions & 1 deletion frontend/src/components/code-engine/code-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { TreeItem, TreeItemIndex } from 'react-complex-tree';
import FileExplorerButton from './file-explorer-button';
import FileStructure from './file-structure';
import { ProjectContext } from './project-context';
import WebPreview from './web-view';

export function CodeEngine({ chatId }: { chatId: string }) {
// Initialize state, refs, and context
Expand Down Expand Up @@ -314,7 +315,7 @@ export function CodeEngine({ chatId }: { chatId: string }) {
</div>
</>
) : activeTab === 'preview' ? (
<div className="flex-1 p-4 text-sm">Preview Content (Mock)</div>
<WebPreview></WebPreview>
) : activeTab === 'console' ? (
<div className="flex-1 p-4 text-sm">Console Content (Mock)</div>
) : null}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/code-engine/project-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
const [chatProjectCache, setChatProjectCache] = useState<
Map<string, Project | null>
>(new Map());
const MAX_RETRIES = 20;
const MAX_RETRIES = 100;

useQuery(GET_USER_PROJECTS, {
onCompleted: (data) => setProjects(data.getUserProjects),
Expand Down Expand Up @@ -106,7 +106,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
console.error('Error polling chat:', error);
}

await new Promise((resolve) => setTimeout(resolve, 5000));
await new Promise((resolve) => setTimeout(resolve, 6000));
retries++;
}

Expand Down
84 changes: 84 additions & 0 deletions frontend/src/components/code-engine/web-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { ProjectContext } from './project-context';

export default function WebPreview() {
const { curProject } = useContext(ProjectContext);
const [url, setUrl] = useState('');
const iframeRef = useRef(null);

useEffect(() => {
const getWebUrl = async () => {
const projectPath = curProject.projectPath;
try {
const response = await fetch(
`/api/runProject?projectPath=${encodeURIComponent(projectPath)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
const json = await response.json();
console.log(json);
await new Promise((resolve) => setTimeout(resolve, 10000));
setUrl(`http://${json.domain}/`);
} catch (error) {
console.error('fetching url error:', error);
}
};

getWebUrl();
}, [curProject]);

useEffect(() => {
if (iframeRef.current) {
iframeRef.current.src = url;
}
}, [url]);

const refreshIframe = () => {
if (iframeRef.current) {
iframeRef.current.src = url;
}
};

const enterFullScreen = () => {
if (iframeRef.current) {
iframeRef.current.requestFullscreen();
}
};

return (
<div className="flex flex-col items-center gap-4 p-4 w-full">
<div className="flex gap-2 w-full max-w-2xl">
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex-1 p-2 border rounded"
/>
<button
onClick={refreshIframe}
className="p-2 bg-blue-500 text-white rounded"
>
refresh
</button>
<button
onClick={enterFullScreen}
className="p-2 bg-green-500 text-white rounded"
>
fullscreen
</button>
</div>

<div className="w-full h-full max-w-5xl border rounded-lg overflow-hidden">
<iframe
ref={iframeRef}
src={url}
className="w-full h-full border-none"
/>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion frontend/src/graphql/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ type Project {
projectPackages: [ProjectPackages!]
projectPath: String!
updatedAt: Date!
user: User!
userId: ID!
}

Expand All @@ -141,7 +142,7 @@ type Query {
getChatDetails(chatId: String!): Chat
getChatHistory(chatId: String!): [Message!]!
getHello: String!
getProjectDetails(projectId: String!): Project!
getProject(projectId: String!): Project!
getUserChats: [Chat!]
getUserProjects: [Project!]!
isValidateProject(isValidProject: IsValidProjectInput!): Boolean!
Expand Down Expand Up @@ -181,6 +182,7 @@ type User {
email: String!
isActive: Boolean!
isDeleted: Boolean!
projects: [Project!]!
updatedAt: Date!
username: String!
}

0 comments on commit 9a65a54

Please sign in to comment.