'use client'; import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Deployment, Project } from '@/lib/vfs/types'; import { vfs } from '@/lib/vfs'; import { getLoginUrl } from '@/lib/config/storage'; import { getSyncManager } from '@/lib/vfs/sync-manager'; import { DeploymentCard } from '../deployment-card'; import { DeploymentSettingsModal } from '../deployment-settings'; import { ServerSettingsModal } from '../server-settings'; import { CreateDeploymentModal } from '../create-deployment-modal'; import { AnalyticsDashboard } from '../analytics-dashboard'; import { TemplateExportDialog } from '../templates/template-export-dialog'; import { ProjectSwapDialog } from '../project-swap-dialog'; import { Globe, Plus, Search, ArrowUpDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from 'sonner'; import { logger } from '@/lib/utils'; import { track } from '@/lib/telemetry'; import { usePagination } from '@/lib/hooks/use-pagination'; import { Pagination, PaginationRange } from '@/components/ui/pagination'; type SortOption = 'updated' | 'created' | 'name' | 'published'; interface DeploymentsViewProps { onProjectSelect: (project: Project) => void; workspaceId?: string; } export function DeploymentsView({ onProjectSelect, workspaceId }: DeploymentsViewProps) { const apiBase = workspaceId ? `/api/w/${workspaceId}` : '/api'; const [deployments, setDeployments] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [publishingStates, setPublishingStates] = useState>({}); const [selectedDeployment, setSelectedDeployment] = useState(null); const [showSettingsModal, setShowSettingsModal] = useState(false); const [showServerSettingsModal, setShowServerSettingsModal] = useState(false); const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false); const [showTemplateExportModal, setShowTemplateExportModal] = useState(false); const [templateExportDeployment, setTemplateExportDeployment] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [sortBy, setSortBy] = useState('updated'); const [swapDialogState, setSwapDialogState] = useState<{ deploymentId: string; deploymentName: string; currentProjectId: string; newProjectId: string; newProjectName: string; pendingSettings: Partial; } | null>(null); const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true'; useEffect(() => { loadData(); }, []); const loadData = async () => { try { setLoading(true); if (!isServerMode) { setLoading(false); return; } const [deploymentsResponse, projectsResponse] = await Promise.all([ fetch(`${apiBase}/deployments`), fetch(`${apiBase}/projects?fields=id,name`), // Only fetch id and name fields ]); // Redirect to login if unauthorized if (deploymentsResponse.status === 401 || projectsResponse.status === 401) { window.location.href = getLoginUrl(); return; } if (!deploymentsResponse.ok) { throw new Error('Failed to fetch deployments'); } if (!projectsResponse.ok) { throw new Error('Failed to fetch projects'); } const [fetchedDeployments, fetchedProjects] = await Promise.all([ deploymentsResponse.json(), projectsResponse.json(), ]); setDeployments(fetchedDeployments); setProjects(fetchedProjects); } catch (error) { logger.error('[DeploymentsView] Failed to load data:', error); } finally { setLoading(false); } }; // Helper function to update a single deployment in state (optimistic updates) const updateDeploymentInState = (deploymentId: string, updates: Partial) => { setDeployments(prevDeployments => prevDeployments.map(deployment => deployment.id === deploymentId ? { ...deployment, ...updates } : deployment ) ); }; const handleOpenSettings = (deployment: Deployment) => { setSelectedDeployment(deployment); setShowSettingsModal(true); }; const handleOpenServerSettings = (deployment: Deployment) => { setSelectedDeployment(deployment); setShowServerSettingsModal(true); }; const handleViewAnalytics = (deployment: Deployment) => { setSelectedDeployment(deployment); setShowAnalyticsModal(true); }; const handleEditProject = async (deployment: Deployment) => { try { await vfs.init(); const project = await vfs.getProject(deployment.projectId); if (!project) { toast.error('Project not found in local storage'); return; } onProjectSelect(project); } catch (error) { logger.error('[DeploymentsView] Failed to load project:', error); toast.error('Failed to load project'); } }; const [templateExportProject, setTemplateExportProject] = useState(null); const handleExportAsTemplate = async (deployment: Deployment) => { try { await vfs.init(); const project = await vfs.getProject(deployment.projectId); if (!project) { toast.error('Project not found in local storage'); return; } setTemplateExportDeployment(deployment); setTemplateExportProject(project); setShowTemplateExportModal(true); } catch (error) { logger.error('[DeploymentsView] Failed to load project for template export:', error); toast.error('Failed to load project'); } }; const handleSaveSettings = async (settings: Partial) => { if (!selectedDeployment) return; try { // If projectId changed in server mode, show swap dialog for conflict analysis const projectIdChanged = settings.projectId && settings.projectId !== selectedDeployment.projectId; if (projectIdChanged && isServerMode && selectedDeployment.publishedAt) { const newProject = projects.find(p => p.id === settings.projectId); setSwapDialogState({ deploymentId: selectedDeployment.id, deploymentName: selectedDeployment.name, currentProjectId: selectedDeployment.projectId, newProjectId: settings.projectId!, newProjectName: newProject?.name || 'Unknown', pendingSettings: settings, }); return; // Don't save yet — swap dialog will handle it } if (projectIdChanged) { // Non-server mode or first deploy: update directly const deploymentResponse = await fetch(`${apiBase}/deployments/${selectedDeployment.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: settings.projectId }), }); if (!deploymentResponse.ok) { const error = await deploymentResponse.json(); throw new Error(error.error || 'Failed to update project'); } } // Save publishing settings (exclude projectId — handled above) const { projectId: _projectId, ...publishSettings } = settings; const response = await fetch(`${apiBase}/deployments/${selectedDeployment.id}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(publishSettings), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to save settings'); } const result = await response.json(); // Fire only on the empty -> set transition, never on every save and // never with the domain value itself. if (!selectedDeployment.customDomain && settings.customDomain) { track('custom_domain_set'); } // Update local deployment state in the selected deployment modal setSelectedDeployment({ ...selectedDeployment, ...settings, settingsVersion: result.settingsVersion, lastPublishedVersion: result.lastPublishedVersion, }); // Update the deployment in the main list (optimistic update - no full reload) updateDeploymentInState(selectedDeployment.id, { ...settings, settingsVersion: result.settingsVersion, updatedAt: new Date(), }); } catch (error) { logger.error('[DeploymentsView] Failed to save deployment settings:', error); throw error; } }; const handleSwapComplete = async () => { if (!swapDialogState) return; // Swap + republish completed via the swap API. // Now save any remaining publishing settings. const { deploymentId, pendingSettings } = swapDialogState; const { projectId: _projectId, ...publishSettings } = pendingSettings; try { const response = await fetch(`${apiBase}/deployments/${deploymentId}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(publishSettings), }); if (response.ok) { const result = await response.json(); updateDeploymentInState(deploymentId, { ...pendingSettings, settingsVersion: result.settingsVersion, updatedAt: new Date(), publishedAt: new Date(), }); } } catch (err) { logger.error('[DeploymentsView] Failed to save settings after swap:', err); } setSwapDialogState(null); setShowSettingsModal(false); setSelectedDeployment(null); toast.success('Project swapped and deployment republished'); await loadData(); }; const handlePublish = async (deploymentId: string) => { const deployment = deployments.find(s => s.id === deploymentId); if (!deployment) return; if (!confirm('Publish this deployment with the current settings?')) { return; } // Set publishing state setPublishingStates(prev => ({ ...prev, [deploymentId]: true })); let trackedRuntime = 'unknown'; try { // First, sync files from IndexedDB to server toast.info('Syncing project files...'); await vfs.init(); const project = await vfs.getProject(deployment.projectId); if (!project) { throw new Error('Project not found in local storage'); } // Block publishing for terminal-mode runtimes (Python, Lua) — they can't be // served as static sites. Use ZIP export instead. const { isRuntimeBundled, getRuntimeConfig } = await import('@/lib/runtimes/registry'); const runtime = project.settings?.runtime; trackedRuntime = runtime ?? 'unknown'; if (runtime && getRuntimeConfig(runtime).previewMode === 'terminal') { throw new Error(`${getRuntimeConfig(runtime).label} projects cannot be published as static sites. Use ZIP export instead.`); } let files = await vfs.listFiles(deployment.projectId); if (runtime && isRuntimeBundled(runtime)) { // Always compile fresh — generated files may be stale or absent // (e.g. user publishes without opening the preview first) toast.info('Compiling project bundles...'); const { VirtualServer } = await import('@/lib/preview/virtual-server'); const vs = new VirtualServer(vfs as any, deployment.projectId, { runtime }); await vs.compileProject(); vs.cleanupBlobUrls(); const generatedFiles = vfs.getGeneratedFiles(); if (generatedFiles.length > 0) { const withProjectId = generatedFiles.map(f => ({ ...f, projectId: deployment.projectId })); files = [...files, ...withProjectId]; } } const syncManager = getSyncManager(); // Push project and files to server const syncResult = await syncManager.pushProjectWithFiles(project, files); if (!syncResult.success) { throw new Error(syncResult.error || 'Failed to sync files to server'); } // Sync backend features from IndexedDB to server const adapter = await vfs.getStorageAdapter(); const edgeFunctions = adapter.listEdgeFunctions ? await adapter.listEdgeFunctions(deployment.projectId) : []; const serverFunctions = adapter.listServerFunctions ? await adapter.listServerFunctions(deployment.projectId) : []; const secrets = adapter.listSecrets ? await adapter.listSecrets(deployment.projectId) : []; const scheduledFunctions = adapter.listScheduledFunctions ? await adapter.listScheduledFunctions(deployment.projectId) : []; if (edgeFunctions.length > 0 || serverFunctions.length > 0 || secrets.length > 0 || scheduledFunctions.length > 0) { toast.info('Syncing backend features...'); const featuresSyncResult = await syncManager.pushBackendFeatures(deployment.projectId, { edgeFunctions, serverFunctions, secrets, scheduledFunctions, }); if (!featuresSyncResult.success) { throw new Error(featuresSyncResult.error || 'Failed to sync backend features'); } } toast.info('Building deployment...'); // Call publish API to trigger build const response = await fetch(`${apiBase}/deployments/${deploymentId}/publish`, { method: 'POST', }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to publish'); } const result = await response.json(); track('deployment_publish', { runtime: runtime ?? 'unknown', result: 'success', has_custom_domain: !!deployment.customDomain, }); toast.success(`Deployment published! ${result.filesWritten} files written.`); // Update state optimistically with publish data updateDeploymentInState(deploymentId, { lastPublishedVersion: result.lastPublishedVersion, publishedAt: new Date(), updatedAt: new Date(), databaseEnabled: true, ...(result.slug && { slug: result.slug }), }); // Clear publishing state immediately after successful publish setPublishingStates(prev => ({ ...prev, [deploymentId]: false })); } catch (error) { logger.error('Failed to publish:', error); track('deployment_publish', { runtime: trackedRuntime, result: 'fail', has_custom_domain: !!deployment.customDomain, }); toast.error(error instanceof Error ? error.message : 'Failed to publish. Please try again.'); // Clear publishing state on error setPublishingStates(prev => ({ ...prev, [deploymentId]: false })); } }; const handleDeploymentThumbnailChange = async (deploymentId: string, image: string | undefined) => { try { const response = await fetch(`${apiBase}/deployments/${deploymentId}/thumbnail`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewImage: image ?? null }), }); if (!response.ok) throw new Error('Failed to update thumbnail'); updateDeploymentInState(deploymentId, { previewImage: image, previewUpdatedAt: image ? new Date() : undefined, }); } catch (err) { logger.error('[DeploymentsView] Failed to update deployment thumbnail:', err); toast.error('Failed to update thumbnail'); } }; const handleDisable = async (deploymentId: string) => { const deployment = deployments.find(s => s.id === deploymentId); if (!deployment) return; if (!confirm('Disable this deployment? It will no longer be publicly accessible.')) { return; } try { const response = await fetch(`${apiBase}/deployments/${deploymentId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: false, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to disable deployment'); } // Update state optimistically (no full reload) updateDeploymentInState(deploymentId, { enabled: false, updatedAt: new Date(), }); } catch (error) { logger.error('Failed to disable deployment:', error); alert('Failed to disable deployment. Please try again.'); } }; const handleEnable = async (deploymentId: string) => { const deployment = deployments.find(s => s.id === deploymentId); if (!deployment) return; try { const response = await fetch(`${apiBase}/deployments/${deploymentId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to enable deployment'); } // Update state optimistically (no full reload) updateDeploymentInState(deploymentId, { enabled: true, updatedAt: new Date(), }); } catch (error) { logger.error('Failed to enable deployment:', error); alert('Failed to enable deployment. Please try again.'); } }; const handleDelete = async (deploymentId: string) => { const deployment = deployments.find(s => s.id === deploymentId); if (!deployment) return; if (!confirm(`Delete deployment "${deployment.name}"? This cannot be undone.`)) { return; } try { const response = await fetch(`${apiBase}/deployments/${deploymentId}`, { method: 'DELETE', }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete deployment'); } track('deployment_delete'); await loadData(); } catch (error) { logger.error('Failed to delete deployment:', error); alert('Failed to delete deployment. Please try again.'); } }; const handleCreateDeployment = async (data: { projectId: string; name: string; slug?: string }) => { try { const response = await fetch(`${apiBase}/deployments`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to create deployment'); } track('deployment_create'); await loadData(); setShowCreateModal(false); } catch (error) { logger.error('Failed to create deployment:', error); throw error; } }; // Filter and sort deployments const filteredAndSortedDeployments = useMemo(() => { let filtered = deployments; // Apply search filter if (searchQuery) { const query = searchQuery.toLowerCase(); filtered = deployments.filter(deployment => { const project = projects.find(p => p.id === deployment.projectId); return ( deployment.name.toLowerCase().includes(query) || deployment.slug?.toLowerCase().includes(query) || project?.name.toLowerCase().includes(query) ); }); } // Apply sorting const sorted = [...filtered].sort((a, b) => { switch (sortBy) { case 'name': return a.name.localeCompare(b.name); case 'created': return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); case 'published': if (!a.publishedAt && !b.publishedAt) return 0; if (!a.publishedAt) return 1; if (!b.publishedAt) return -1; return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(); case 'updated': default: return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); } }); return sorted; }, [deployments, projects, searchQuery, sortBy]); const deploymentsPagination = usePagination(filteredAndSortedDeployments, { perPage: 24, resetOn: [searchQuery, sortBy], }); const listScrollRef = useRef(null); if (!isServerMode) { return (

Deployments feature is only available in Server Mode

); } if (loading) { return (

Loading deployments...

); } return ( <>
{/* Toolbar */}
{/* Search */}
setSearchQuery(e.target.value)} className="pl-9" />
{/* Controls */}
{/* Sort */}

Sort by

{/* New Deployment */}
{/* Deployments Grid/List */}
{filteredAndSortedDeployments.length === 0 ? (
{deployments.length === 0 ? ( <>

No Deployments Yet

Create your first deployment by clicking the "New" button above. Deployments let you publish projects and manage their public settings independently.

) : ( <>

No deployments found

Try adjusting your search or filter criteria

)}
) : ( <>
{deploymentsPagination.pageItems.map((deployment) => { const project = projects.find(p => p.id === deployment.projectId); return ( ); })}
)}
{selectedDeployment && ( <> { setShowSettingsModal(false); setSelectedDeployment(null); }} onSave={handleSaveSettings} /> { setShowServerSettingsModal(false); setSelectedDeployment(null); }} workspaceId={workspaceId} /> { setShowAnalyticsModal(false); setSelectedDeployment(null); }} /> )} setShowCreateModal(false)} onCreate={handleCreateDeployment} /> { setShowTemplateExportModal(open); if (!open) { setTemplateExportDeployment(null); setTemplateExportProject(null); } }} /> {swapDialogState && ( setSwapDialogState(null)} deploymentId={swapDialogState.deploymentId} deploymentName={swapDialogState.deploymentName} currentProjectId={swapDialogState.currentProjectId} newProjectId={swapDialogState.newProjectId} newProjectName={swapDialogState.newProjectName} onSwapComplete={handleSwapComplete} workspaceId={workspaceId} /> )} ); }