import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; import { classNames } from '~/utils/classNames'; import { useStore } from '@nanostores/react'; import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify'; import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify'; import { Button } from '~/components/ui/Button'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; import { formatDistanceToNow } from 'date-fns'; import { Badge } from '~/components/ui/Badge'; interface ConnectionTestResult { status: 'success' | 'error' | 'testing'; message: string; timestamp?: number; } interface SiteAction { name: string; icon: string; action: (siteId: string) => Promise; requiresConfirmation?: boolean; variant?: 'default' | 'destructive' | 'outline'; } // Netlify logo SVG component const NetlifyLogo = () => ( ); export default function NetlifyTab() { const connection = useStore(netlifyConnection); const [tokenInput, setTokenInput] = useState(''); const [fetchingStats, setFetchingStats] = useState(false); const [sites, setSites] = useState([]); const [deploys, setDeploys] = useState([]); const [deploymentCount, setDeploymentCount] = useState(0); const [lastUpdated, setLastUpdated] = useState(''); const [isStatsOpen, setIsStatsOpen] = useState(false); const [activeSiteIndex, setActiveSiteIndex] = useState(0); const [isSitesExpanded, setIsSitesExpanded] = useState(false); const [isDeploysExpanded, setIsDeploysExpanded] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [connectionTest, setConnectionTest] = useState(null); // Connection testing function const testConnection = async () => { if (!connection.token) { setConnectionTest({ status: 'error', message: 'No token provided', timestamp: Date.now(), }); return; } setConnectionTest({ status: 'testing', message: 'Testing connection...', }); try { const response = await fetch('https://api.netlify.com/api/v1/user', { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (response.ok) { const data = (await response.json()) as any; setConnectionTest({ status: 'success', message: `Connected successfully as ${data.email}`, timestamp: Date.now(), }); } else { setConnectionTest({ status: 'error', message: `Connection failed: ${response.status} ${response.statusText}`, timestamp: Date.now(), }); } } catch (error) { setConnectionTest({ status: 'error', message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, timestamp: Date.now(), }); } }; // Site actions const siteActions: SiteAction[] = [ { name: 'Clear Cache', icon: 'i-ph:arrows-clockwise', action: async (siteId: string) => { try { setIsActionLoading(true); // Try to get site details first to check for build hooks const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!siteResponse.ok) { const errorText = await siteResponse.text(); if (siteResponse.status === 404) { toast.error('Site not found. This may be a free account limitation.'); return; } throw new Error(`Failed to get site details: ${errorText}`); } const siteData = (await siteResponse.json()) as any; // Check if this looks like a free account (limited features) const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter'; // If site has build hooks, try triggering a build instead if (siteData.build_settings && siteData.build_settings.repo_url) { // Try to trigger a build by making a POST to the site's build endpoint const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { method: 'POST', headers: { Authorization: `Bearer ${connection.token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ clear_cache: true, }), }); if (buildResponse.ok) { toast.success('Build triggered with cache clear'); return; } else if (buildResponse.status === 422) { // Often indicates free account limitation toast.warning('Build trigger failed. This feature may not be available on free accounts.'); return; } } // Fallback: Try the standard cache purge endpoint const cacheResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/purge_cache`, { method: 'POST', headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!cacheResponse.ok) { if (cacheResponse.status === 404) { if (isFreeAccount) { toast.warning('Cache purge not available on free accounts. Try triggering a build instead.'); } else { toast.error('Cache purge endpoint not found. This feature may not be available.'); } return; } const errorText = await cacheResponse.text(); throw new Error(`Cache purge failed: ${errorText}`); } toast.success('Site cache cleared successfully'); } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to clear site cache: ${error}`); } finally { setIsActionLoading(false); } }, }, { name: 'Manage Environment', icon: 'i-ph:gear', action: async (siteId: string) => { try { setIsActionLoading(true); // Get site info first to check account type const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!siteResponse.ok) { throw new Error('Failed to get site details'); } const siteData = (await siteResponse.json()) as any; const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter'; // Get environment variables const envResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/env`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (envResponse.ok) { const envVars = (await envResponse.json()) as any[]; toast.success(`Environment variables loaded: ${envVars.length} variables`); } else if (envResponse.status === 404) { if (isFreeAccount) { toast.info('Environment variables management is limited on free accounts'); } else { toast.info('Site has no environment variables configured'); } } else { const errorText = await envResponse.text(); toast.error(`Failed to load environment variables: ${errorText}`); } } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to load environment variables: ${error}`); } finally { setIsActionLoading(false); } }, }, { name: 'Trigger Build', icon: 'i-ph:rocket-launch', action: async (siteId: string) => { try { setIsActionLoading(true); const buildResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/builds`, { method: 'POST', headers: { Authorization: `Bearer ${connection.token}`, 'Content-Type': 'application/json', }, }); if (!buildResponse.ok) { throw new Error('Failed to trigger build'); } const buildData = (await buildResponse.json()) as any; toast.success(`Build triggered successfully! ID: ${buildData.id}`); } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to trigger build: ${error}`); } finally { setIsActionLoading(false); } }, }, { name: 'View Functions', icon: 'i-ph:code', action: async (siteId: string) => { try { setIsActionLoading(true); // Get site info first to check account type const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!siteResponse.ok) { throw new Error('Failed to get site details'); } const siteData = (await siteResponse.json()) as any; const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter'; const functionsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/functions`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (functionsResponse.ok) { const functions = (await functionsResponse.json()) as any[]; toast.success(`Site has ${functions.length} serverless functions`); } else if (functionsResponse.status === 404) { if (isFreeAccount) { toast.info('Functions may be limited or unavailable on free accounts'); } else { toast.info('Site has no serverless functions'); } } else { const errorText = await functionsResponse.text(); toast.error(`Failed to load functions: ${errorText}`); } } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to load functions: ${error}`); } finally { setIsActionLoading(false); } }, }, { name: 'Site Analytics', icon: 'i-ph:chart-bar', action: async (siteId: string) => { try { setIsActionLoading(true); // Get site info first to check account type const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!siteResponse.ok) { throw new Error('Failed to get site details'); } const siteData = (await siteResponse.json()) as any; const isFreeAccount = !siteData.plan || siteData.plan === 'free' || siteData.plan === 'starter'; // Get site traffic data (if available) const analyticsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/traffic`, { headers: { Authorization: `Bearer ${connection.token}`, }, }); if (analyticsResponse.ok) { await analyticsResponse.json(); // Analytics data received toast.success('Site analytics loaded successfully'); } else if (analyticsResponse.status === 404) { if (isFreeAccount) { toast.info('Analytics not available on free accounts. Showing basic site info instead.'); } // Fallback to basic site info toast.info(`Site: ${siteData.name} - Status: ${siteData.state || 'Unknown'}`); } else { const errorText = await analyticsResponse.text(); if (isFreeAccount) { toast.info( 'Analytics unavailable on free accounts. Site info: ' + `${siteData.name} (${siteData.state || 'Unknown'})`, ); } else { toast.error(`Failed to load analytics: ${errorText}`); } } } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to load site analytics: ${error}`); } finally { setIsActionLoading(false); } }, }, { name: 'Delete Site', icon: 'i-ph:trash', action: async (siteId: string) => { try { const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!response.ok) { throw new Error('Failed to delete site'); } toast.success('Site deleted successfully'); fetchNetlifyStats(connection.token); } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to delete site: ${error}`); } }, requiresConfirmation: true, variant: 'destructive', }, ]; // Deploy management functions const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => { try { setIsActionLoading(true); const endpoint = action === 'publish' ? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore` : `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`; const response = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${connection.token}`, }, }); if (!response.ok) { throw new Error(`Failed to ${action} deploy`); } toast.success(`Deploy ${action}ed successfully`); fetchNetlifyStats(connection.token); } catch (err: unknown) { const error = err instanceof Error ? err.message : 'Unknown error'; toast.error(`Failed to ${action} deploy: ${error}`); } finally { setIsActionLoading(false); } }; useEffect(() => { // Initialize connection with environment token if available initializeNetlifyConnection(); }, []); useEffect(() => { // Check if we have a connection with a token but no stats if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) { fetchNetlifyStats(connection.token); } // Update local state from connection if (connection.stats) { setSites(connection.stats.sites || []); setDeploys(connection.stats.deploys || []); setDeploymentCount(connection.stats.deploys?.length || 0); setLastUpdated(connection.stats.lastDeployTime || ''); } }, [connection]); const handleConnect = async () => { if (!tokenInput) { toast.error('Please enter a Netlify API token'); return; } setIsConnecting(true); try { const response = await fetch('https://api.netlify.com/api/v1/user', { headers: { Authorization: `Bearer ${tokenInput}`, }, }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const userData = (await response.json()) as NetlifyUser; // Update the connection store updateNetlifyConnection({ user: userData, token: tokenInput, }); toast.success('Connected to Netlify successfully'); // Fetch stats after successful connection fetchNetlifyStats(tokenInput); } catch (error) { console.error('Error connecting to Netlify:', error); toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsConnecting(false); setTokenInput(''); } }; const handleDisconnect = () => { // Clear from localStorage localStorage.removeItem('netlify_connection'); // Remove cookies document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; // Update the store updateNetlifyConnection({ user: null, token: '' }); setConnectionTest(null); toast.success('Disconnected from Netlify'); }; const fetchNetlifyStats = async (token: string) => { setFetchingStats(true); try { // Fetch sites const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', { headers: { Authorization: `Bearer ${token}`, }, }); if (!sitesResponse.ok) { throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`); } const sitesData = (await sitesResponse.json()) as NetlifySite[]; setSites(sitesData); // Fetch deploys and builds for ALL sites const allDeploysData: NetlifyDeploy[] = []; const allBuildsData: NetlifyBuild[] = []; let lastDeployTime = ''; let totalDeploymentCount = 0; if (sitesData && sitesData.length > 0) { // Process sites in batches to avoid overwhelming the API const batchSize = 3; const siteBatches = []; for (let i = 0; i < sitesData.length; i += batchSize) { siteBatches.push(sitesData.slice(i, i + batchSize)); } for (const batch of siteBatches) { const batchPromises = batch.map(async (site) => { try { // Fetch deploys for this site const deploysResponse = await fetch( `https://api.netlify.com/api/v1/sites/${site.id}/deploys?per_page=20`, { headers: { Authorization: `Bearer ${token}`, }, }, ); let siteDeploys: NetlifyDeploy[] = []; if (deploysResponse.ok) { siteDeploys = (await deploysResponse.json()) as NetlifyDeploy[]; } // Fetch builds for this site const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${site.id}/builds?per_page=10`, { headers: { Authorization: `Bearer ${token}`, }, }); let siteBuilds: NetlifyBuild[] = []; if (buildsResponse.ok) { siteBuilds = (await buildsResponse.json()) as NetlifyBuild[]; } return { site, deploys: siteDeploys, builds: siteBuilds }; } catch (error) { console.error(`Failed to fetch data for site ${site.name}:`, error); return { site, deploys: [], builds: [] }; } }); const batchResults = await Promise.all(batchPromises); for (const result of batchResults) { allDeploysData.push(...result.deploys); allBuildsData.push(...result.builds); totalDeploymentCount += result.deploys.length; } // Small delay between batches if (batch !== siteBatches[siteBatches.length - 1]) { await new Promise((resolve) => setTimeout(resolve, 200)); } } // Sort deploys by creation date (newest first) allDeploysData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); // Set the most recent deploy time if (allDeploysData.length > 0) { lastDeployTime = allDeploysData[0].created_at; setLastUpdated(lastDeployTime); } setDeploys(allDeploysData); setDeploymentCount(totalDeploymentCount); } // Update the stats in the store updateNetlifyConnection({ stats: { sites: sitesData, deploys: allDeploysData, builds: allBuildsData, lastDeployTime, totalSites: sitesData.length, totalDeploys: totalDeploymentCount, totalBuilds: allBuildsData.length, }, }); toast.success('Netlify stats updated'); } catch (error) { console.error('Error fetching Netlify stats:', error); toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setFetchingStats(false); } }; const renderStats = () => { if (!connection.user || !connection.stats) { return null; } return (
Netlify Stats
{/* Netlify Overview Dashboard */}

Netlify Overview

{connection.stats.totalSites}
Total Sites
{connection.stats.totalDeploys || deploymentCount}
Total Deployments
{connection.stats.totalBuilds || 0}
Total Builds
{sites.filter((site) => site.published_deploy?.state === 'ready').length}
Live Sites
{/* Advanced Analytics */}

Deployment Analytics

Success Rate
{(() => { const successfulDeploys = deploys.filter((deploy) => deploy.state === 'ready').length; const failedDeploys = deploys.filter((deploy) => deploy.state === 'error').length; const successRate = deploys.length > 0 ? Math.round((successfulDeploys / deploys.length) * 100) : 0; return [ { label: 'Success Rate', value: `${successRate}%` }, { label: 'Successful', value: successfulDeploys }, { label: 'Failed', value: failedDeploys }, ]; })().map((item, idx) => (
{item.label}: {item.value}
))}
Recent Activity
{(() => { const now = Date.now(); const last24Hours = deploys.filter( (deploy) => now - new Date(deploy.created_at).getTime() < 24 * 60 * 60 * 1000, ).length; const last7Days = deploys.filter( (deploy) => now - new Date(deploy.created_at).getTime() < 7 * 24 * 60 * 60 * 1000, ).length; const activeSites = sites.filter((site) => { const lastDeploy = site.published_deploy?.published_at; return lastDeploy && now - new Date(lastDeploy).getTime() < 7 * 24 * 60 * 60 * 1000; }).length; return [ { label: 'Last 24 hours', value: last24Hours }, { label: 'Last 7 days', value: last7Days }, { label: 'Active sites', value: activeSites }, ]; })().map((item, idx) => (
{item.label}: {item.value}
))}
{/* Site Health Metrics */}

Site Health Overview

{(() => { const healthySites = sites.filter( (site) => site.published_deploy?.state === 'ready' && site.ssl_url, ).length; const sslEnabled = sites.filter((site) => !!site.ssl_url).length; const customDomain = sites.filter((site) => !!site.custom_domain).length; const needsAttention = sites.filter( (site) => site.published_deploy?.state === 'error' || !site.published_deploy, ).length; const buildingSites = sites.filter( (site) => site.published_deploy?.state === 'building' || site.published_deploy?.state === 'processing', ).length; return [ { label: 'Healthy', value: healthySites, icon: 'i-ph:heart', color: 'text-green-500', bgColor: 'bg-green-100 dark:bg-green-900/20', textColor: 'text-green-800 dark:text-green-400', }, { label: 'SSL Enabled', value: sslEnabled, icon: 'i-ph:lock', color: 'text-blue-500', bgColor: 'bg-blue-100 dark:bg-blue-900/20', textColor: 'text-blue-800 dark:text-blue-400', }, { label: 'Custom Domain', value: customDomain, icon: 'i-ph:globe', color: 'text-purple-500', bgColor: 'bg-purple-100 dark:bg-purple-900/20', textColor: 'text-purple-800 dark:text-purple-400', }, { label: 'Building', value: buildingSites, icon: 'i-ph:gear', color: 'text-yellow-500', bgColor: 'bg-yellow-100 dark:bg-yellow-900/20', textColor: 'text-yellow-800 dark:text-yellow-400', }, { label: 'Needs Attention', value: needsAttention, icon: 'i-ph:warning', color: 'text-red-500', bgColor: 'bg-red-100 dark:bg-red-900/20', textColor: 'text-red-800 dark:text-red-400', }, ]; })().map((metric, index) => (
{metric.label}
{metric.value}
))}
{connection.stats.totalSites} Sites
{deploymentCount} Deployments
{connection.stats.totalBuilds || 0} Builds {lastUpdated && (
Updated {formatDistanceToNow(new Date(lastUpdated))} ago )}
{sites.length > 0 && (

Your Sites ({sites.length})

{sites.length > 8 && ( )}
{(isSitesExpanded ? sites : sites.slice(0, 8)).map((site, index) => (
{ setActiveSiteIndex(index); }} >
{site.name}
{site.published_deploy?.state === 'ready' ? (
) : (
)} {site.published_deploy?.state || 'Unknown'}
e.stopPropagation()} >
{site.published_deploy?.framework && (
{site.published_deploy.framework}
)} {site.custom_domain && (
Custom Domain
)} {site.branch && (
{site.branch}
)}
{activeSiteIndex === index && ( <>
{siteActions.map((action) => ( ))}
{site.published_deploy && (
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
{site.published_deploy.branch && (
Branch: {site.published_deploy.branch}
)}
)} )}
))}
{deploys.length > 0 && (

All Deployments ({deploys.length})

{deploys.length > 10 && ( )}
{(isDeploysExpanded ? deploys : deploys.slice(0, 10)).map((deploy) => (
{deploy.state === 'ready' ? (
) : deploy.state === 'error' ? (
) : (
)} {deploy.state}
{formatDistanceToNow(new Date(deploy.created_at))} ago
{deploy.branch && (
Branch: {deploy.branch}
)} {deploy.deploy_url && (
e.stopPropagation()} > )}
{deploy.state === 'ready' ? ( ) : ( )}
))}
)} {/* Builds Section */} {connection.stats.builds && connection.stats.builds.length > 0 && (

Recent Builds ({connection.stats.builds.length})

{connection.stats.builds.slice(0, 8).map((build: any) => (
{build.done ? (
) : (
)} {build.done ? 'Completed' : 'Building'}
{formatDistanceToNow(new Date(build.created_at))} ago
{build.commit_ref && (
{build.commit_ref.substring(0, 7)}
)}
))}
)}
)}
); }; return (
{/* Header */}

Netlify Integration

{connection.user && ( )}

Connect and manage your Netlify sites with advanced deployment controls and site management

{/* Connection Test Results */} {connectionTest && (
{connectionTest.status === 'success' && (
)} {connectionTest.status === 'error' && (
)} {connectionTest.status === 'testing' && (
)} {connectionTest.message}
{connectionTest.timestamp && (

{new Date(connectionTest.timestamp).toLocaleString()}

)} )} {/* Main Connection Component */}
{!connection.user ? (

Tip: You can also set the{' '} VITE_NETLIFY_ACCESS_TOKEN {' '} environment variable to connect automatically.

setTokenInput(e.target.value)} placeholder="Enter your Netlify API token" className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', 'border border-[#E5E5E5] dark:border-[#333333]', 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive', 'disabled:opacity-50', )} />
) : (
Connected to Netlify
{renderStats()}
)}
); }