From fb8e0eff47b9936644a1967f8adfc71da0f4b986 Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Sun, 30 Mar 2025 13:40:33 -0700 Subject: [PATCH] Add deployment UI (#88) --- app/components/header/DeployChatButton.tsx | 364 ++++++++++++++++++ app/components/header/Feedback.tsx | 2 +- app/components/header/Header.tsx | 7 + app/lib/persistence/db.ts | 39 +- app/lib/replay/Deploy.ts | 65 ++++ .../20250328175522_create_chats_table.sql | 7 +- 6 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 app/components/header/DeployChatButton.tsx create mode 100644 app/lib/replay/Deploy.ts diff --git a/app/components/header/DeployChatButton.tsx b/app/components/header/DeployChatButton.tsx new file mode 100644 index 00000000..8a0ae949 --- /dev/null +++ b/app/components/header/DeployChatButton.tsx @@ -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(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(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 ( + <> + + + {isModalOpen && ( +
+
+ {status === DeployStatus.Succeeded ? ( + <> +
Deployment Succeeded
+
+
+ + + + +
+
+ + ) : ( + <> +

Deploy

+
+ Deploy this chat's project to production. +
+ + {deploySettings?.siteURL && ( + + )} + +
+ + { + const netlify = { + authToken: e.target.value, + siteId: deploySettings?.netlify?.siteId || '', + createInfo: deploySettings?.netlify?.createInfo || undefined, + }; + setDeploySettings({ + ...deploySettings, + netlify, + }); + }} + /> + + { + const netlify = { + authToken: deploySettings?.netlify?.authToken || '', + siteId: e.target.value, + createInfo: deploySettings?.netlify?.createInfo || undefined, + }; + setDeploySettings({ + ...deploySettings, + netlify, + }); + }} + /> + + { + 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, + }); + }} + /> + + { + 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, + }); + }} + /> + + { + const supabase = { + databaseURL: e.target.value, + anonKey: deploySettings?.supabase?.anonKey || '', + postgresURL: deploySettings?.supabase?.postgresURL || '', + }; + setDeploySettings({ + ...deploySettings, + supabase, + }); + }} + /> + + { + const supabase = { + databaseURL: deploySettings?.supabase?.databaseURL || '', + anonKey: e.target.value, + postgresURL: deploySettings?.supabase?.postgresURL || '', + }; + setDeploySettings({ + ...deploySettings, + supabase, + }); + }} + /> + + { + const supabase = { + databaseURL: deploySettings?.supabase?.databaseURL || '', + anonKey: deploySettings?.supabase?.anonKey || '', + postgresURL: e.target.value, + }; + setDeploySettings({ + ...deploySettings, + supabase, + }); + }} + /> +
+ +
+ {status === DeployStatus.Started && ( +
+ +
+ )} + + {status === DeployStatus.NotStarted && ( + + )} + +
+ + {error && ( +
+ {error} +
+ )} + + )} +
+
+ )} + + ); +} diff --git a/app/components/header/Feedback.tsx b/app/components/header/Feedback.tsx index da25ab08..fd989bef 100644 --- a/app/components/header/Feedback.tsx +++ b/app/components/header/Feedback.tsx @@ -137,7 +137,7 @@ export function Feedback() {
diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index 88edb34f..5067d19f 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -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() { )} + {chat.started && ( + + {() => } + + )} +
{chat.started && ( diff --git a/app/lib/persistence/db.ts b/app/lib/persistence/db.ts index 017fd9f8..477cb62d 100644 --- a/app/lib/persistence/db.ts +++ b/app/lib/persistence/db.ts @@ -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 { 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 { 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 { 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 { + 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 { + const { error } = await getSupabase().from('chats').update({ deploy_settings: deploySettings }).eq('id', id); + + if (error) { + throw error; + } +} diff --git a/app/lib/replay/Deploy.ts b/app/lib/replay/Deploy.ts new file mode 100644 index 00000000..2c4b9e2e --- /dev/null +++ b/app/lib/replay/Deploy.ts @@ -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 { + const { result } = await sendCommandDedicatedClient({ + method: 'Nut.deployRepository', + params: { + repositoryId, + settings, + }, + }) as { result: DeployResult }; + + return result; +} diff --git a/supabase/migrations/20250328175522_create_chats_table.sql b/supabase/migrations/20250328175522_create_chats_table.sql index a6d1e822..f4c62069 100644 --- a/supabase/migrations/20250328175522_create_chats_table.sql +++ b/supabase/migrations/20250328175522_create_chats_table.sql @@ -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