Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hosting UI, First draft #906

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/studio/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ VITE_MIXPANEL_TOKEN=
VITE_ANTHROPIC_API_KEY=
VITE_OPENAI_API_KEY=
VITE_LANGFUSE_SECRET_KEY=
VITE_LANGFUSE_PUBLIC_KEY=
VITE_LANGFUSE_PUBLIC_KEY=
VITE_ZONKE_API_ENDPOINT=
VITE_ZONKE_API_KEY=
VITE_ZONKE_API_TOKEN=
30 changes: 30 additions & 0 deletions apps/studio/electron/main/events/hosting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { MainChannels } from '@onlook/models/constants';
import { ipcMain } from 'electron';
import hostingManager from '../hosting';

export function listenForHostingMessages() {
ipcMain.handle(
MainChannels.CREATE_PROJECT_HOSTING_ENV,
(e: Electron.IpcMainInvokeEvent, args) => {
return hostingManager.createEnv(args);
},
);

ipcMain.handle(MainChannels.GET_PROJECT_HOSTING_ENV, (e: Electron.IpcMainInvokeEvent, args) => {
return hostingManager.getEnv(args.envId);
});

ipcMain.handle(MainChannels.GET_DEPLOYMENT_STATUS, (e: Electron.IpcMainInvokeEvent, args) => {
return hostingManager.getDeploymentStatus(args.envId, args.versionId);
});

ipcMain.handle(
MainChannels.PUBLISH_PROJECT_HOSTING_ENV,
(e: Electron.IpcMainInvokeEvent, args) => {
const { envId, folderPath, buildScript } = args;
return hostingManager.publishEnv(envId, folderPath, buildScript);
},
);


}
2 changes: 2 additions & 0 deletions apps/studio/electron/main/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { listenForAuthMessages } from './auth';
import { listenForChatMessages } from './chat';
import { listenForCodeMessages } from './code';
import { listenForCreateMessages } from './create';
import { listenForHostingMessages } from './hosting';
import { listenForRunMessages } from './run';
import { listenForStorageMessages } from './storage';

Expand All @@ -22,6 +23,7 @@ export function listenForIpcMessages() {
listenForCreateMessages();
listenForChatMessages();
listenForRunMessages();
listenForHostingMessages();
}

function listenForGeneralMessages() {
Expand Down
187 changes: 187 additions & 0 deletions apps/studio/electron/main/hosting/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { MainChannels } from '@onlook/models/constants';
import { DeployState, VersionStatus, type CreateEnvOptions, type DeploymentStatus } from '@onlook/models/hosting';
import { PreviewEnvironmentClient, SupportedFrameworks } from '@zonke-cloud/sdk';
import { exec } from 'node:child_process';
import { mainWindow } from '..';
import { PersistentStorage } from '../storage';
const MOCK_ENV = {
endpoint: 'o95ewhbkzx.preview.zonke.market',
environmentId: '850540f8-a168-43a6-9772-6a1727d73b93',
versions: [
{
message: 'Testing',
environmentId: '850540f8-a168-43a6-9772-6a1727d73b93',
buildOutputDirectory: '/Users/kietho/workplace/onlook/test/docs/.next'
}
],
};

class HostingManager {
private static instance: HostingManager;

private zonke: PreviewEnvironmentClient;
private userId: string | null = null;
private state: DeployState = DeployState.NONE;

private constructor() {
this.restoreSettings();
this.zonke = this.initZonkeClient();
}

initZonkeClient() {
if (
!import.meta.env.VITE_ZONKE_API_KEY ||
!import.meta.env.VITE_ZONKE_API_TOKEN ||
!import.meta.env.VITE_ZONKE_API_ENDPOINT
) {
throw new Error('Zonke API key, token, and endpoint must be set');
}
return new PreviewEnvironmentClient({
apiKey: import.meta.env.VITE_ZONKE_API_KEY,
apiToken: import.meta.env.VITE_ZONKE_API_TOKEN,
apiEndpoint: import.meta.env.VITE_ZONKE_API_ENDPOINT,
});
}

public static getInstance(): HostingManager {
if (!HostingManager.instance) {
HostingManager.instance = new HostingManager();
}
return HostingManager.instance;
}

private restoreSettings() {
const settings = PersistentStorage.USER_SETTINGS.read() || {};
this.userId = settings.id || null;
}

createEnv(options: CreateEnvOptions) {
if (this.userId === null) {
console.error('User ID not found');
return;
}

const framework = options.framework as SupportedFrameworks;
const awsHostedZone = 'zonke.market';

return this.zonke.createPreviewEnvironment({
userId: this.userId,
framework,
awsHostedZone,
});
}

async getEnv(envId: string) {
try {
return await this.zonke.getPreviewEnvironment(envId);
} catch (error) {
console.error('Failed to get preview environment', error);
return null;
}
}

async publishEnv(envId: string, folderPath: string, buildScript: string) {
console.log('Publishing environment', {
envId,
folderPath,
buildScript,
});

// TODO: Infer this from project
const BUILD_OUTPUT_PATH = folderPath + '/.next';

try {
this.setState(DeployState.BUILDING, 'Building project');
const success = await this.runBuildScript(folderPath, buildScript);
if (!success) {
this.setState(DeployState.ERROR, 'Build failed');
return null;
}

this.setState(DeployState.DEPLOYING, 'Deploying to preview environment');
const version = await this.zonke.deployToPreviewEnvironment({
message: 'New deployment',
environmentId: envId,
buildOutputDirectory: BUILD_OUTPUT_PATH,
});

this.pollDeploymentStatus(envId, version.versionId);
return version;

} catch (error) {
console.error('Failed to deploy to preview environment', error);
this.setState(DeployState.ERROR, 'Deployment failed');
return null;
}
}

pollDeploymentStatus(envId: string, versionId: string) {
const interval = 3000;
const timeout = 300000;
const startTime = Date.now();

const intervalId = setInterval(async () => {
try {
const status = await this.getDeploymentStatus(envId, versionId);

if (status.status === VersionStatus.SUCCEEDED) {
clearInterval(intervalId);
const env = await this.getEnv(envId);
this.setState(DeployState.DEPLOYED, 'Deployment successful', env?.endpoint);
} else if (status.status === VersionStatus.FAILED) {
clearInterval(intervalId);
this.setState(DeployState.ERROR, 'Deployment failed');
} else if (Date.now() - startTime > timeout) {
clearInterval(intervalId);
this.setState(DeployState.ERROR, 'Deployment timed out');
}
} catch (error) {
clearInterval(intervalId);
this.setState(DeployState.ERROR, 'Failed to check deployment status');
}
}, interval);

setTimeout(() => {
clearInterval(intervalId);
}, timeout);
}

async getDeploymentStatus(envId: string, versionId: string) {
return await this.zonke.getDeploymentStatus({
environmentId: envId,
sourceVersion: versionId,
});
}

runBuildScript(folderPath: string, buildScript: string): Promise<boolean> {
this.setState(DeployState.BUILDING, 'Building project');

return new Promise((resolve, reject) => {
exec(buildScript, { cwd: folderPath, env: { ...process.env, NODE_ENV: 'production' } }, (error: Error | null, stdout: string, stderr: string) => {
if (error) {
console.error(`Build script error: ${error}`);
resolve(false);
return;
}

if (stderr) {
console.warn(`Build script stderr: ${stderr}`);
}

console.log(`Build script output: ${stdout}`);
resolve(true);
});
});
}

setState(state: DeployState, message?: string, endpoint?: string) {
this.state = state;
mainWindow?.webContents.send(MainChannels.DEPLOY_STATE_CHANGED, { state, message, endpoint });
}

getState(): DeploymentStatus {
return { state: this.state };
}
}

export default HostingManager.getInstance();
95 changes: 95 additions & 0 deletions apps/studio/src/lib/projects/hosting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { MainChannels } from '@onlook/models/constants';
import { DeployState } from '@onlook/models/hosting';
import type { Project } from '@onlook/models/projects';
import type { PreviewEnvironment } from '@zonke-cloud/sdk';
import { makeAutoObservable } from 'mobx';
import { invokeMainChannel } from '../utils';

export class HostingManager {
private project: Project;
env: PreviewEnvironment | null = null;
state: DeployState = DeployState.NONE;
message: string = '';

constructor(project: Project) {
makeAutoObservable(this);
this.project = project;
this.restoreState();
this.listenForStateChanges();

}

async listenForStateChanges() {
const res = await this.getDeploymentStatus('850540f8-a168-43a6-9772-6a1727d73b93', 'eYu9codOymFSFLt6e634lu073BkaWSQo');
console.log(res);

window.api.on(MainChannels.DEPLOY_STATE_CHANGED, async (args) => {
const { state, message } = args as { state: DeployState; message: string };
this.state = state;
this.message = message;
});
}

async restoreState() {
this.env = await this.getEnv();
}

async create() {
const res: PreviewEnvironment | null = await invokeMainChannel(
MainChannels.CREATE_PROJECT_HOSTING_ENV,
{
userId: 'testUserId',
framework: 'nextjs',
},
);
if (!res) {
console.error('Failed to create hosting environment');
return;
}
this.env = res;
}

async getEnv() {
const res = await invokeMainChannel(MainChannels.GET_PROJECT_HOSTING_ENV, {
envId: '850540f8-a168-43a6-9772-6a1727d73b93',
});
return res as PreviewEnvironment | null;
}

async publish() {
const folderPath = this.project.folderPath;
const buildScript: string = this.project.commands?.build || 'npm run build';
const envId = this.env?.environmentId;

if (!folderPath || !buildScript || !envId) {
console.error('Missing required data for publishing');
return;
}

const res = await invokeMainChannel(MainChannels.PUBLISH_PROJECT_HOSTING_ENV, {
folderPath,
buildScript,
envId,
});

if (!res) {
console.error('Failed to publish hosting environment');
}
}

get isDeploying() {
return [DeployState.BUILDING, DeployState.DEPLOYING].includes(this.state);
}

async restart() { }

async dispose() { }

async getDeploymentStatus(envId: string, versionId: string) {
const res = await invokeMainChannel(MainChannels.GET_DEPLOYMENT_STATUS, {
envId,
versionId,
});
return res;
}
}
Loading