mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Add deployment UI (#88)
This commit is contained in:
parent
8fb01c5586
commit
fb8e0eff47
364
app/components/header/DeployChatButton.tsx
Normal file
364
app/components/header/DeployChatButton.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useState } from 'react';
|
||||
import type { DeploySettingsDatabase } from '~/lib/replay/Deploy';
|
||||
import { generateRandomId } from '~/lib/replay/ReplayProtocolClient';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { databaseGetChatDeploySettings, databaseUpdateChatDeploySettings } from '~/lib/persistence/db';
|
||||
import { currentChatId } from '~/lib/persistence/useChatHistory';
|
||||
import { deployRepository } from '~/lib/replay/Deploy';
|
||||
|
||||
ReactModal.setAppElement('#root');
|
||||
|
||||
// Component for deploying a chat to production.
|
||||
|
||||
enum DeployStatus {
|
||||
NotStarted,
|
||||
Started,
|
||||
Succeeded,
|
||||
}
|
||||
|
||||
export function DeployChatButton() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [deploySettings, setDeploySettings] = useState<DeploySettingsDatabase | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<DeployStatus>(DeployStatus.NotStarted);
|
||||
|
||||
const handleOpenModal = async () => {
|
||||
const chatId = currentChatId.get();
|
||||
if (!chatId) {
|
||||
toast.error('No chat open');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSettings = await databaseGetChatDeploySettings(chatId);
|
||||
|
||||
setIsModalOpen(true);
|
||||
setStatus(DeployStatus.NotStarted);
|
||||
|
||||
if (existingSettings) {
|
||||
setDeploySettings(existingSettings);
|
||||
} else {
|
||||
setDeploySettings({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
setError(null);
|
||||
|
||||
const chatId = currentChatId.get();
|
||||
if (!chatId) {
|
||||
setError('No chat open');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deploySettings?.netlify?.authToken) {
|
||||
setError('Netlify Auth Token is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deploySettings?.netlify?.siteId) {
|
||||
if (deploySettings.netlify.createInfo) {
|
||||
setError('Cannot specify both a Netlify Site ID and a Netlify Account Slug');
|
||||
return;
|
||||
}
|
||||
} else if (!deploySettings?.netlify?.createInfo) {
|
||||
setError('Either a Netlify Site ID or a Netlify Account Slug is required');
|
||||
return;
|
||||
} else {
|
||||
// Add a default site name if one isn't provided.
|
||||
if (!deploySettings.netlify.createInfo?.siteName) {
|
||||
deploySettings.netlify.createInfo.siteName = `nut-app-${generateRandomId()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (deploySettings?.supabase?.databaseURL || deploySettings?.supabase?.anonKey || deploySettings?.supabase?.postgresURL) {
|
||||
if (!deploySettings.supabase.databaseURL) {
|
||||
setError('Supabase Database URL is required');
|
||||
return;
|
||||
}
|
||||
if (!deploySettings.supabase.anonKey) {
|
||||
setError('Supabase Anonymous Key is required');
|
||||
return;
|
||||
}
|
||||
if (!deploySettings.supabase.postgresURL) {
|
||||
setError('Supabase Postgres URL is required');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const repositoryId = workbenchStore.repositoryId.get();
|
||||
if (!repositoryId) {
|
||||
setError('No repository ID found');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(DeployStatus.Started);
|
||||
|
||||
// Write out to the database before we start trying to deploy.
|
||||
await databaseUpdateChatDeploySettings(chatId, deploySettings);
|
||||
|
||||
console.log('DeploymentStarting', repositoryId, deploySettings);
|
||||
|
||||
const result = await deployRepository(repositoryId, deploySettings);
|
||||
|
||||
console.log('DeploymentResult', repositoryId, deploySettings, result);
|
||||
|
||||
if (result.error) {
|
||||
setStatus(DeployStatus.NotStarted);
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let newSettings = deploySettings;
|
||||
|
||||
// Update netlify settings so future deployments will reuse the site.
|
||||
if (deploySettings?.netlify?.createInfo && result.netlifySiteId) {
|
||||
newSettings = {
|
||||
...deploySettings,
|
||||
netlify: { authToken: deploySettings.netlify.authToken, siteId: result.netlifySiteId },
|
||||
};
|
||||
}
|
||||
|
||||
// Update database with the deployment result.
|
||||
newSettings = {
|
||||
...newSettings,
|
||||
siteURL: result.siteURL,
|
||||
repositoryId,
|
||||
};
|
||||
|
||||
setDeploySettings(newSettings);
|
||||
setStatus(DeployStatus.Succeeded);
|
||||
|
||||
// Update the database with the new settings.
|
||||
await databaseUpdateChatDeploySettings(chatId, newSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex gap-2 bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
onClick={() => {
|
||||
handleOpenModal();
|
||||
}}
|
||||
>
|
||||
Deploy
|
||||
</button>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full z-50">
|
||||
{status === DeployStatus.Succeeded ? (
|
||||
<>
|
||||
<div className="text-center mb-2">Deployment Succeeded</div>
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<a href={deploySettings?.siteURL} target="_blank" rel="noopener noreferrer">
|
||||
<button className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors">
|
||||
{deploySettings?.siteURL}
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-center mb-4">Deploy</h2>
|
||||
<div className="text-center mb-4">
|
||||
Deploy this chat's project to production.
|
||||
</div>
|
||||
|
||||
{deploySettings?.siteURL && (
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-lg text-gray-700 pr-2">Existing site:</span>
|
||||
<a href={deploySettings?.siteURL} target="_blank" rel="noopener noreferrer">
|
||||
<button className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors">
|
||||
{deploySettings?.siteURL}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-4 items-center">
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Netlify Auth Token:</label>
|
||||
<input
|
||||
name="netlifyAuthToken"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.netlify?.authToken}
|
||||
placeholder="nfp_..."
|
||||
onChange={(e) => {
|
||||
const netlify = {
|
||||
authToken: e.target.value,
|
||||
siteId: deploySettings?.netlify?.siteId || '',
|
||||
createInfo: deploySettings?.netlify?.createInfo || undefined,
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
netlify,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Netlify Site ID (existing site):</label>
|
||||
<input
|
||||
name="netlifySiteId"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.netlify?.siteId}
|
||||
placeholder="123e4567-..."
|
||||
onChange={(e) => {
|
||||
const netlify = {
|
||||
authToken: deploySettings?.netlify?.authToken || '',
|
||||
siteId: e.target.value,
|
||||
createInfo: deploySettings?.netlify?.createInfo || undefined,
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
netlify,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Netlify Account Slug (new site):</label>
|
||||
<input
|
||||
name="netlifyAccountSlug"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.netlify?.createInfo?.accountSlug}
|
||||
placeholder="abc..."
|
||||
onChange={(e) => {
|
||||
const createInfo = {
|
||||
accountSlug: e.target.value,
|
||||
siteName: deploySettings?.netlify?.createInfo?.siteName || '',
|
||||
};
|
||||
const netlify = {
|
||||
authToken: deploySettings?.netlify?.authToken || '',
|
||||
siteId: deploySettings?.netlify?.siteId || '',
|
||||
createInfo,
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
netlify,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Netlify Site Name (new site):</label>
|
||||
<input
|
||||
name="netlifySiteName"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.netlify?.createInfo?.siteName}
|
||||
placeholder="my-chat-app..."
|
||||
onChange={(e) => {
|
||||
const createInfo = {
|
||||
accountSlug: deploySettings?.netlify?.createInfo?.accountSlug || '',
|
||||
siteName: e.target.value,
|
||||
};
|
||||
const netlify = {
|
||||
authToken: deploySettings?.netlify?.authToken || '',
|
||||
siteId: deploySettings?.netlify?.siteId || '',
|
||||
createInfo,
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
netlify,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Supabase Database URL:</label>
|
||||
<input
|
||||
name="supabaseDatabaseURL"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.supabase?.databaseURL}
|
||||
placeholder="https://abc...def.supabase.co"
|
||||
onChange={(e) => {
|
||||
const supabase = {
|
||||
databaseURL: e.target.value,
|
||||
anonKey: deploySettings?.supabase?.anonKey || '',
|
||||
postgresURL: deploySettings?.supabase?.postgresURL || '',
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
supabase,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Supabase Anonymous Key:</label>
|
||||
<input
|
||||
name="supabaseAnonKey"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.supabase?.anonKey}
|
||||
placeholder="ey..."
|
||||
onChange={(e) => {
|
||||
const supabase = {
|
||||
databaseURL: deploySettings?.supabase?.databaseURL || '',
|
||||
anonKey: e.target.value,
|
||||
postgresURL: deploySettings?.supabase?.postgresURL || '',
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
supabase,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-lg text-gray-700 text-right">Supabase Postgres URL:</label>
|
||||
<input
|
||||
name="supabasePostgresURL"
|
||||
className="bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary rounded px-2 py-2 border border-gray-300"
|
||||
value={deploySettings?.supabase?.postgresURL}
|
||||
placeholder="postgresql://postgres:<password>@db.abc...def.supabase.co:5432/postgres"
|
||||
onChange={(e) => {
|
||||
const supabase = {
|
||||
databaseURL: deploySettings?.supabase?.databaseURL || '',
|
||||
anonKey: deploySettings?.supabase?.anonKey || '',
|
||||
postgresURL: e.target.value,
|
||||
};
|
||||
setDeploySettings({
|
||||
...deploySettings,
|
||||
supabase,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
{status === DeployStatus.Started && (
|
||||
<div className="w-full text-bolt-elements-textSecondary flex items-center">
|
||||
<span className="i-svg-spinners:3-dots-fade inline-block w-[1em] h-[1em] mr-2 text-4xl"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === DeployStatus.NotStarted && (
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Deploy
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -137,7 +137,7 @@ export function Feedback() {
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleSubmitFeedback}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||
>
|
||||
Submit Feedback
|
||||
</button>
|
||||
|
||||
@ -7,6 +7,7 @@ import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
||||
import { Feedback } from './Feedback';
|
||||
import { Suspense } from 'react';
|
||||
import { ClientAuth } from '~/components/auth/ClientAuth';
|
||||
import { DeployChatButton } from './DeployChatButton';
|
||||
|
||||
export function Header() {
|
||||
const chat = useStore(chatStore);
|
||||
@ -33,6 +34,12 @@ export function Header() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{chat.started && (
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <DeployChatButton />}</ClientOnly>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{chat.started && (
|
||||
<ClientOnly>
|
||||
|
||||
@ -6,6 +6,7 @@ import { getSupabase, getCurrentUserId } from '~/lib/supabase/client';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getMessagesRepositoryId, type Message } from './message';
|
||||
import { assert } from '~/lib/replay/ReplayProtocolClient';
|
||||
import type { DeploySettingsDatabase } from '../replay/Deploy';
|
||||
|
||||
export interface ChatContents {
|
||||
id: string;
|
||||
@ -52,7 +53,7 @@ export async function getAllChats(): Promise<ChatContents[]> {
|
||||
return getLocalChats();
|
||||
}
|
||||
|
||||
const { data, error } = await getSupabase().from('chats').select('*');
|
||||
const { data, error } = await getSupabase().from('chats').select('*').eq('deleted', false);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@ -66,10 +67,16 @@ export async function syncLocalChats(): Promise<void> {
|
||||
const localChats = getLocalChats();
|
||||
|
||||
if (userId && localChats.length) {
|
||||
for (const chat of localChats) {
|
||||
await setChatContents(chat.id, chat.title, chat.messages);
|
||||
try {
|
||||
for (const chat of localChats) {
|
||||
if (chat.title) {
|
||||
await setChatContents(chat.id, chat.title, chat.messages);
|
||||
}
|
||||
}
|
||||
setLocalChats(undefined);
|
||||
} catch (error) {
|
||||
console.error('Error syncing local chats', error);
|
||||
}
|
||||
setLocalChats(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +158,7 @@ export async function deleteById(id: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await getSupabase().from('chats').delete().eq('id', id);
|
||||
const { error } = await getSupabase().from('chats').update({ deleted: true }).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@ -174,3 +181,25 @@ export async function databaseUpdateChatTitle(id: string, title: string): Promis
|
||||
|
||||
await setChatContents(id, title, chat.messages);
|
||||
}
|
||||
|
||||
export async function databaseGetChatDeploySettings(id: string): Promise<DeploySettingsDatabase | undefined> {
|
||||
const { data, error } = await getSupabase().from('chats').select('deploy_settings').eq('id', id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data.length != 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data[0].deploy_settings;
|
||||
}
|
||||
|
||||
export async function databaseUpdateChatDeploySettings(id: string, deploySettings: DeploySettingsDatabase): Promise<void> {
|
||||
const { error } = await getSupabase().from('chats').update({ deploy_settings: deploySettings }).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
65
app/lib/replay/Deploy.ts
Normal file
65
app/lib/replay/Deploy.ts
Normal file
@ -0,0 +1,65 @@
|
||||
|
||||
// State for deploying a chat to production.
|
||||
|
||||
import { sendCommandDedicatedClient } from "./ReplayProtocolClient";
|
||||
|
||||
// Deploy to a Netlify site.
|
||||
interface DeploySettingsNetlify {
|
||||
// Authentication token for Netlify account.
|
||||
authToken: string;
|
||||
|
||||
// ID of any existing site to link to.
|
||||
siteId?: string;
|
||||
|
||||
// Information needed when creating a new site.
|
||||
createInfo?: {
|
||||
accountSlug: string;
|
||||
siteName: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Deploy to a Supabase project.
|
||||
interface DeploySettingsSupabase {
|
||||
// URL of the Supabase project.
|
||||
databaseURL: string;
|
||||
|
||||
// Anonymous key for the Supabase project.
|
||||
anonKey: string;
|
||||
|
||||
// Internal URL of the Postgres database, including password.
|
||||
postgresURL: string;
|
||||
}
|
||||
|
||||
// Deploy settings passed in to the protocol.
|
||||
export interface DeploySettings {
|
||||
netlify?: DeploySettingsNetlify;
|
||||
supabase?: DeploySettingsSupabase;
|
||||
}
|
||||
|
||||
// Deploy result returned from the protocol.
|
||||
export interface DeployResult {
|
||||
error?: string;
|
||||
netlifySiteId?: string;
|
||||
siteURL?: string;
|
||||
}
|
||||
|
||||
// Information about a chat's deployment saved to the database.
|
||||
export interface DeploySettingsDatabase extends DeploySettings {
|
||||
// Last repository which was deployed.
|
||||
repositoryId?: string;
|
||||
|
||||
// URL of the deployed site.
|
||||
siteURL?: string;
|
||||
}
|
||||
|
||||
export async function deployRepository(repositoryId: string, settings: DeploySettings): Promise<DeployResult> {
|
||||
const { result } = await sendCommandDedicatedClient({
|
||||
method: 'Nut.deployRepository',
|
||||
params: {
|
||||
repositoryId,
|
||||
settings,
|
||||
},
|
||||
}) as { result: DeployResult };
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -4,14 +4,11 @@ CREATE TABLE IF NOT EXISTS public.chats (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
user_id UUID REFERENCES auth.users(id),
|
||||
|
||||
-- Available to all users with the chat ID
|
||||
title TEXT NOT NULL,
|
||||
repository_id UUID,
|
||||
|
||||
-- Available only to the owning user and admins
|
||||
messages JSONB DEFAULT '{}',
|
||||
deploy_settings JSONB DEFAULT '{}'
|
||||
deploy_settings JSONB DEFAULT '{}',
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Create updated_at trigger for chats table
|
||||
|
||||
Loading…
Reference in New Issue
Block a user