2079 lines
103 KiB
TypeScript
2079 lines
103 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useCallback } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||
import api from '@/lib/api';
|
||
import { useAuthStore } from '@/lib/store';
|
||
import { toast } from 'react-toastify';
|
||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2 } from 'lucide-react';
|
||
|
||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||
|
||
type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error';
|
||
|
||
const stageLabels: Record<DeployStage, string> = {
|
||
idle: '',
|
||
creating: 'Creating application...',
|
||
'uploading-source': 'Uploading source code...',
|
||
'uploading-db': 'Uploading database dump...',
|
||
paying: 'Processing payment...',
|
||
deploying: 'Starting deployment...',
|
||
done: 'Redirecting...',
|
||
error: 'An error occurred',
|
||
};
|
||
|
||
export default function DeployPage() {
|
||
const router = useRouter();
|
||
const user = useAuthStore((s) => s.user);
|
||
const isAdmin = user?.role === 'admin' || user?.role === 'technical';
|
||
const [step, setStep] = useState(0);
|
||
const [form, setForm] = useState<CreateApplicationDto>({
|
||
name: '',
|
||
description: '',
|
||
runtime: 'nodejs',
|
||
databaseType: 'none',
|
||
runtimeVersion: '20',
|
||
phpVersion: '',
|
||
dbVersion: '',
|
||
gitUrl: '',
|
||
gitToken: '',
|
||
gitBranch: '',
|
||
envVars: {},
|
||
cpuRequest: '100m',
|
||
cpuLimit: '500m',
|
||
memoryRequest: '128Mi',
|
||
memoryLimit: '512Mi',
|
||
replicas: 1,
|
||
port: 3000,
|
||
dbStorageSize: '1',
|
||
appStorageSize: '2',
|
||
enableRedis: false,
|
||
enableRabbitmq: false,
|
||
enableElasticsearch: false,
|
||
});
|
||
const [envKey, setEnvKey] = useState('');
|
||
const [envVal, setEnvVal] = useState('');
|
||
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
|
||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [clusterMode, setClusterMode] = useState<'default' | 'manual' | 'pool'>('default');
|
||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
|
||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||
const [dbUploadProgress, setDbUploadProgress] = useState(0);
|
||
const [deployStage, setDeployStage] = useState<DeployStage>('idle');
|
||
const [wpMode, setWpMode] = useState<'fresh' | 'migrate' | 'public_html'>('fresh');
|
||
const [wpContentFile, setWpContentFile] = useState<File | null>(null);
|
||
const [isWpDragging, setIsWpDragging] = useState(false);
|
||
const wpFileInputRef = useRef<HTMLInputElement>(null);
|
||
const [selectedCycle, setSelectedCycle] = useState<BillingCycle>('monthly');
|
||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||
const [isPaid, setIsPaid] = useState(false);
|
||
|
||
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
|
||
queryKey: ['clusters-public'],
|
||
queryFn: () => api.get('/clusters/public').then((r) => r.data),
|
||
enabled: isAdmin,
|
||
});
|
||
|
||
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
|
||
queryKey: ['pools-public'],
|
||
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
|
||
enabled: isAdmin,
|
||
});
|
||
|
||
// Cost calculation for the review step
|
||
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
|
||
queryFn: () => api.post('/billing/calculate', {
|
||
runtime: form.runtime,
|
||
databaseType: form.databaseType,
|
||
cpuLimit: form.cpuLimit,
|
||
memoryLimit: form.memoryLimit,
|
||
replicas: form.replicas,
|
||
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
|
||
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
|
||
enableRedis: form.enableRedis,
|
||
enableRabbitmq: form.enableRabbitmq,
|
||
enableElasticsearch: form.enableElasticsearch,
|
||
}).then((r) => r.data),
|
||
enabled: step === 3,
|
||
});
|
||
|
||
// Wallet balance for the review step payment
|
||
const { data: walletData } = useQuery<{ balance: number }>({
|
||
queryKey: ['wallet-balance'],
|
||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||
enabled: step === 3,
|
||
});
|
||
|
||
const payAmount = costData ? costData[selectedCycle] : 0;
|
||
const walletBalance = walletData?.balance ?? 0;
|
||
const hasEnoughBalance = walletBalance >= payAmount;
|
||
|
||
const walletPayMutation = useMutation({
|
||
mutationFn: async () => {
|
||
// First create the app
|
||
setDeployStage('creating');
|
||
const payload = { ...form };
|
||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||
}
|
||
if (payload.appStorageSize) {
|
||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||
}
|
||
const res = await api.post('/applications', payload);
|
||
const appId = res.data.id;
|
||
|
||
// Upload source (regular apps or WordPress migrate/public_html)
|
||
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
|
||
if (fileToUpload) {
|
||
setDeployStage('uploading-source');
|
||
setUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', fileToUpload);
|
||
await api.post(`/applications/${appId}/upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||
});
|
||
}
|
||
|
||
// Upload DB dump
|
||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||
setDeployStage('uploading-db');
|
||
setDbUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', dbDumpFile);
|
||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||
});
|
||
}
|
||
|
||
// Deduct from wallet
|
||
setDeployStage('paying');
|
||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||
|
||
return res;
|
||
},
|
||
onSuccess: (res) => {
|
||
setDeployStage('deploying');
|
||
toast.success('Payment successful! Deploying...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
setDeployStage('done');
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
setDeployStage('error');
|
||
toast.error(err.response?.data?.message || 'Payment or deployment failed');
|
||
setUploadProgress(0);
|
||
setDbUploadProgress(0);
|
||
setTimeout(() => setDeployStage('idle'), 2000);
|
||
},
|
||
});
|
||
|
||
const gatewayPayMutation = useMutation({
|
||
mutationFn: async () => {
|
||
// Initiate gateway
|
||
setDeployStage('paying');
|
||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||
amount: payAmount,
|
||
description: `Deploy: ${form.name} (${selectedCycle})`,
|
||
callbackUrl: `${window.location.origin}/dashboard/deploy`,
|
||
});
|
||
|
||
// In production, redirect to gw.gatewayUrl
|
||
// For now, auto-verify (simulated)
|
||
await api.post('/billing/gateway/verify', {
|
||
trackingCode: gw.trackingCode,
|
||
amount: payAmount,
|
||
});
|
||
|
||
// Now create the app
|
||
setDeployStage('creating');
|
||
const payload = { ...form };
|
||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||
}
|
||
if (payload.appStorageSize) {
|
||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||
}
|
||
const res = await api.post('/applications', payload);
|
||
const appId = res.data.id;
|
||
|
||
// Upload source (regular apps or WordPress migrate/public_html)
|
||
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
|
||
if (fileToUpload) {
|
||
setDeployStage('uploading-source');
|
||
setUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', fileToUpload);
|
||
await api.post(`/applications/${appId}/upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => { if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||
});
|
||
}
|
||
|
||
// Upload DB dump
|
||
if (form.databaseType !== 'none' && dbDumpFile) {
|
||
setDeployStage('uploading-db');
|
||
setDbUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', dbDumpFile);
|
||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => { if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total)); },
|
||
});
|
||
}
|
||
|
||
// Deduct from the wallet (which was just charged by gateway)
|
||
setDeployStage('paying');
|
||
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
|
||
|
||
return res;
|
||
},
|
||
onSuccess: (res) => {
|
||
setDeployStage('deploying');
|
||
toast.success('Payment successful! Deploying...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
setDeployStage('done');
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
setDeployStage('error');
|
||
toast.error(err.response?.data?.message || 'Payment failed');
|
||
setUploadProgress(0);
|
||
setDbUploadProgress(0);
|
||
setTimeout(() => setDeployStage('idle'), 2000);
|
||
},
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: CreateApplicationDto) => {
|
||
setDeployStage('creating');
|
||
const res = await api.post('/applications', data);
|
||
const appId = res.data.id;
|
||
|
||
// Upload zip file if selected
|
||
// Upload source (regular apps or WordPress migrate/public_html)
|
||
const fileToUpload = data.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
|
||
if (fileToUpload) {
|
||
setDeployStage('uploading-source');
|
||
setUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', fileToUpload);
|
||
await api.post(`/applications/${appId}/upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => {
|
||
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||
},
|
||
});
|
||
}
|
||
|
||
// Upload DB dump if provided and a database was requested
|
||
if (data.databaseType && data.databaseType !== 'none' && dbDumpFile) {
|
||
setDeployStage('uploading-db');
|
||
setDbUploadProgress(0);
|
||
const formData = new FormData();
|
||
formData.append('file', dbDumpFile);
|
||
await api.post(`/applications/${appId}/db-upload`, formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
onUploadProgress: (e) => {
|
||
if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||
},
|
||
});
|
||
}
|
||
|
||
return res;
|
||
},
|
||
onSuccess: (res) => {
|
||
setDeployStage('deploying');
|
||
toast.success('Application created! Triggering deployment...');
|
||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||
setDeployStage('done');
|
||
router.push(`/dashboard/apps/${res.data.id}`);
|
||
},
|
||
onError: (err: any) => {
|
||
setDeployStage('error');
|
||
toast.error(err.response?.data?.message || 'Failed to create application');
|
||
setUploadProgress(0);
|
||
setDbUploadProgress(0);
|
||
setTimeout(() => setDeployStage('idle'), 2000);
|
||
},
|
||
});
|
||
|
||
const addEnvVar = () => {
|
||
if (envKey.trim()) {
|
||
setForm({ ...form, envVars: { ...form.envVars, [envKey]: envVal } });
|
||
setEnvKey('');
|
||
setEnvVal('');
|
||
}
|
||
};
|
||
|
||
const removeEnvVar = (key: string) => {
|
||
const updated = { ...form.envVars };
|
||
delete updated[key];
|
||
setForm({ ...form, envVars: updated });
|
||
};
|
||
|
||
const handleSubmit = () => {
|
||
const payload = { ...form };
|
||
// Format dbStorageSize with Gi suffix
|
||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||
}
|
||
// Format appStorageSize with Gi suffix (all app types)
|
||
if (payload.appStorageSize) {
|
||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||
}
|
||
createMutation.mutate(payload);
|
||
};
|
||
|
||
const handleFileSelect = useCallback((file: File) => {
|
||
const validTypes = ['application/zip', 'application/x-zip-compressed', 'application/gzip', 'application/x-tar'];
|
||
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
|
||
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||
|
||
if (!hasValidExt && !validTypes.includes(file.type)) {
|
||
toast.error('Only .zip or .tar.gz files are allowed');
|
||
return;
|
||
}
|
||
if (file.size > 100 * 1024 * 1024) {
|
||
toast.error('File size must be less than 100MB');
|
||
return;
|
||
}
|
||
setZipFile(file);
|
||
toast.success(`Selected: ${file.name}`);
|
||
}, []);
|
||
|
||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) handleFileSelect(file);
|
||
}, [handleFileSelect]);
|
||
|
||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
}, []);
|
||
|
||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
}, []);
|
||
|
||
const handleWpFileSelect = useCallback((file: File) => {
|
||
const validExtensions = ['.zip', '.tar.gz', '.tgz'];
|
||
const hasValidExt = validExtensions.some((ext) => file.name.toLowerCase().endsWith(ext));
|
||
if (!hasValidExt) {
|
||
toast.error('Only .zip or .tar.gz files are allowed');
|
||
return;
|
||
}
|
||
if (file.size > 200 * 1024 * 1024) {
|
||
toast.error('WordPress files must be less than 200MB');
|
||
return;
|
||
}
|
||
setWpContentFile(file);
|
||
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB)
|
||
const fileSizeGb = file.size / (1024 * 1024 * 1024);
|
||
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5));
|
||
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) }));
|
||
toast.success(`WordPress files selected: ${file.name}`);
|
||
}, []);
|
||
|
||
const handleWpDrop = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsWpDragging(false);
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) handleWpFileSelect(file);
|
||
}, [handleWpFileSelect]);
|
||
|
||
const canNext = () => {
|
||
if (step === 0) {
|
||
if (form.name.length < 2) return false;
|
||
// WordPress: migrate or public_html mode requires wp-content file
|
||
if (form.runtime === 'wordpress') {
|
||
if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false;
|
||
} else {
|
||
if (sourceMethod === 'upload' && !zipFile) return false;
|
||
if (sourceMethod === 'git' && !form.gitUrl) return false;
|
||
}
|
||
return true;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||
<div>
|
||
<h1 className="page-title">Deploy New Application</h1>
|
||
<p className="page-subtitle">Follow the steps to deploy your app to the cloud.</p>
|
||
</div>
|
||
|
||
{/* Step indicator */}
|
||
<div className="flex items-center justify-between">
|
||
{steps.map((label, i) => (
|
||
<div key={label} className="flex items-center flex-1 last:flex-none">
|
||
<div className="flex flex-col items-center">
|
||
<div className={`flex items-center justify-center w-9 h-9 rounded-full text-sm font-bold transition-all duration-300 ${
|
||
i < step
|
||
? 'bg-emerald-500 text-white shadow-md'
|
||
: i === step
|
||
? 'bg-primary-600 text-white shadow-lg ring-4 ring-primary-100'
|
||
: 'bg-gray-200 text-gray-500'
|
||
}`}>
|
||
{i < step ? '✓' : i + 1}
|
||
</div>
|
||
<span className={`mt-1.5 text-xs font-medium hidden sm:block ${
|
||
i <= step ? 'text-gray-900' : 'text-gray-400'
|
||
}`}>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
{i < steps.length - 1 && (
|
||
<div className={`flex-1 h-0.5 mx-2 sm:mx-3 rounded-full transition-colors duration-300 ${
|
||
i < step ? 'bg-emerald-400' : 'bg-gray-200'
|
||
}`} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="card">
|
||
{/* Step 0: Basic Info */}
|
||
{step === 0 && (
|
||
<div className="space-y-5">
|
||
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Name</label>
|
||
<input
|
||
className="input-field"
|
||
placeholder="my-awesome-app"
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })}
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-400">Lowercase letters, numbers, and hyphens only</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
||
<textarea
|
||
className="input-field"
|
||
rows={3}
|
||
placeholder="What does this app do?"
|
||
value={form.description}
|
||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
{/* Application Runtime */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-3">Application Type</label>
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{[
|
||
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-5 h-5 text-green-500" />, desc: 'Express, NestJS, Fastify' },
|
||
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-5 h-5 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
|
||
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-5 h-5 text-blue-600" />, desc: 'Official image' },
|
||
{ value: 'go', label: 'Go', icon: <Hexagon className="w-5 h-5 text-cyan-500" />, desc: 'Gin, Echo, Fiber' },
|
||
{ value: 'python', label: 'Python', icon: <Hexagon className="w-5 h-5 text-yellow-500" />, desc: 'Flask, FastAPI' },
|
||
{ value: 'django', label: 'Django', icon: <Hexagon className="w-5 h-5 text-green-700" />, desc: 'Python web framework' },
|
||
{ value: 'php', label: 'PHP', icon: <Hexagon className="w-5 h-5 text-indigo-500" />, desc: 'Plain PHP apps' },
|
||
{ value: 'dotnet', label: '.NET', icon: <Hexagon className="w-5 h-5 text-purple-500" />, desc: 'ASP.NET Core' },
|
||
].map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => {
|
||
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
||
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
|
||
else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; }
|
||
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
|
||
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
|
||
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||
else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
|
||
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
|
||
setForm({ ...form, ...updates });
|
||
// Reset WordPress-specific state when switching types
|
||
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
|
||
}}
|
||
className={`p-3 rounded-xl border-2 text-left transition-colors ${
|
||
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
{opt.icon}
|
||
<p className="mt-1.5 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||
<p className="text-xs text-gray-500">{opt.desc}</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Source Code Method — hidden for WordPress */}
|
||
{form.runtime !== 'wordpress' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-3">Source Code</label>
|
||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSourceMethod('upload')}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
sourceMethod === 'upload'
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<FolderUp className="w-5 h-5 mx-auto text-gray-500" />
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">Upload ZIP</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSourceMethod('git')}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
sourceMethod === 'git'
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<LinkIcon className="w-5 h-5 mx-auto text-gray-500" />
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
|
||
</button>
|
||
</div>
|
||
|
||
{sourceMethod === 'git' ? (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Repository URL</label>
|
||
<input
|
||
className="input-field"
|
||
placeholder="https://github.com/user/repo.git"
|
||
value={form.gitUrl}
|
||
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">
|
||
Access Token <span className="text-gray-400">(for private repos)</span>
|
||
</label>
|
||
<input
|
||
className="input-field"
|
||
type="password"
|
||
placeholder="ghp_xxxxxxxxxxxx or glpat-xxxxxxxxxxxx"
|
||
value={form.gitToken || ''}
|
||
onChange={(e) => setForm({ ...form, gitToken: e.target.value })}
|
||
/>
|
||
<p className="mt-1 text-xs text-gray-400">
|
||
GitHub: Personal Access Token · GitLab: Project Access Token · Leave empty for public repos
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">
|
||
Branch <span className="text-gray-400">(default: main)</span>
|
||
</label>
|
||
<input
|
||
className="input-field"
|
||
placeholder="main"
|
||
value={form.gitBranch || ''}
|
||
onChange={(e) => setForm({ ...form, gitBranch: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{zipFile ? (
|
||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
|
||
<CheckCircle className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-green-800">{zipFile.name}</p>
|
||
<p className="text-xs text-green-600">
|
||
{(zipFile.size / (1024 * 1024)).toFixed(2)} MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setZipFile(null);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
}}
|
||
className="text-sm text-red-500 hover:text-red-700 font-medium"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||
isDragging
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="space-y-2">
|
||
<Package className="w-8 h-8 mx-auto text-gray-400" />
|
||
<p className="text-sm font-medium text-gray-700">
|
||
Drag & drop your project ZIP here
|
||
</p>
|
||
<p className="text-xs text-gray-500">
|
||
or click to browse • <strong>.zip</strong> files only • Max 100MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".zip,.tar.gz,.tgz"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleFileSelect(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* WordPress deployment mode — shown when WordPress is selected */}
|
||
{form.runtime === 'wordpress' && (
|
||
<div className="space-y-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Deployment Mode</label>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setWpMode('fresh'); setWpContentFile(null); }}
|
||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||
wpMode === 'fresh' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Rocket className="w-5 h-5 text-blue-600" />
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">Fresh Install</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Start with a clean WordPress. Install themes & plugins via the admin panel.
|
||
</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setWpMode('migrate'); setWpContentFile(null); }}
|
||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||
wpMode === 'migrate' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Upload className="w-5 h-5 text-emerald-600" />
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">Migrate Existing Site</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Upload your WordPress files (wp-content, themes, plugins) and optionally a DB dump.
|
||
</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setWpMode('public_html'); setWpContentFile(null); }}
|
||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||
wpMode === 'public_html' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<FolderUp className="w-5 h-5 text-purple-600" />
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">Upload public_html</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Upload your entire public_html directory (full WordPress root) and deploy.
|
||
</p>
|
||
</button>
|
||
</div>
|
||
|
||
{wpMode === 'fresh' && (
|
||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Hexagon className="w-5 h-5 text-blue-600" />
|
||
<h3 className="text-sm font-semibold text-gray-800">WordPress (Official Image)</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-500">
|
||
A fresh WordPress installation will be deployed using the official Docker image.
|
||
You can install themes and plugins via the WordPress admin panel after deployment.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{wpMode === 'migrate' && (
|
||
<div className="space-y-3">
|
||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||
<p className="text-xs text-gray-600">
|
||
<strong>Upload a ZIP</strong> containing your WordPress files. Supported structures:
|
||
</p>
|
||
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
|
||
<li><code className="bg-amber-100 px-1 rounded">wp-content/</code> — themes, plugins, uploads</li>
|
||
<li><code className="bg-amber-100 px-1 rounded">wp-config.php</code> — custom configuration (optional)</li>
|
||
<li>Any custom <code className="bg-amber-100 px-1 rounded">.php</code> files at root level</li>
|
||
</ul>
|
||
<p className="text-xs text-gray-500 mt-1.5">
|
||
You can also upload a SQL database dump in the next step to restore your data.
|
||
</p>
|
||
</div>
|
||
|
||
{wpContentFile ? (
|
||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
|
||
<CheckCircle className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-green-800">{wpContentFile.name}</p>
|
||
<p className="text-xs text-green-600">
|
||
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setWpContentFile(null);
|
||
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
|
||
}}
|
||
className="text-sm text-red-500 hover:text-red-700 font-medium"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDrop={handleWpDrop}
|
||
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
|
||
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
|
||
onClick={() => wpFileInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||
isWpDragging
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="space-y-2">
|
||
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
|
||
<p className="text-sm font-medium text-gray-700">
|
||
Drag & drop your WordPress ZIP here
|
||
</p>
|
||
<p className="text-xs text-gray-500">
|
||
ZIP with <strong>wp-content/</strong> folder • Max 200MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={wpFileInputRef}
|
||
type="file"
|
||
accept=".zip,.tar.gz,.tgz"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleWpFileSelect(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{wpMode === 'public_html' && (
|
||
<div className="space-y-3">
|
||
<div className="p-3 bg-purple-50 border border-purple-200 rounded-xl">
|
||
<p className="text-xs text-gray-600">
|
||
<strong>Upload a ZIP</strong> of your entire <code className="bg-purple-100 px-1 rounded">public_html</code> directory (the full WordPress root):
|
||
</p>
|
||
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
|
||
<li><code className="bg-purple-100 px-1 rounded">wp-admin/</code>, <code className="bg-purple-100 px-1 rounded">wp-includes/</code>, <code className="bg-purple-100 px-1 rounded">wp-content/</code></li>
|
||
<li><code className="bg-purple-100 px-1 rounded">wp-config.php</code>, <code className="bg-purple-100 px-1 rounded">.htaccess</code>, and all root PHP files</li>
|
||
</ul>
|
||
<p className="text-xs text-gray-500 mt-1.5">
|
||
The system auto-detects the full WordPress root and deploys it accordingly.
|
||
You can also upload a SQL database dump in the next step to restore your data.
|
||
</p>
|
||
</div>
|
||
|
||
{wpContentFile ? (
|
||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
|
||
<CheckCircle className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-green-800">{wpContentFile.name}</p>
|
||
<p className="text-xs text-green-600">
|
||
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setWpContentFile(null);
|
||
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
|
||
}}
|
||
className="text-sm text-red-500 hover:text-red-700 font-medium"
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDrop={handleWpDrop}
|
||
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
|
||
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
|
||
onClick={() => wpFileInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||
isWpDragging
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="space-y-2">
|
||
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
|
||
<p className="text-sm font-medium text-gray-700">
|
||
Drag & drop your public_html ZIP here
|
||
</p>
|
||
<p className="text-xs text-gray-500">
|
||
ZIP with full WordPress root (<strong>wp-admin/</strong>, <strong>wp-content/</strong>, ...) • Max 200MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={wpFileInputRef}
|
||
type="file"
|
||
accept=".zip,.tar.gz,.tgz"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleWpFileSelect(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 1: Versions & Database */}
|
||
{step === 1 && (
|
||
<div className="space-y-6">
|
||
<h2 className="text-lg font-semibold text-gray-900">Versions & Database</h2>
|
||
|
||
{/* Runtime Version Selectors */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
{form.runtime === 'nodejs' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Node.js Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.runtimeVersion || '20'}
|
||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||
>
|
||
<option value="22">Node.js 22 (LTS)</option>
|
||
<option value="20">Node.js 20 (LTS)</option>
|
||
<option value="18">Node.js 18</option>
|
||
<option value="16">Node.js 16</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{form.runtime === 'laravel' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.phpVersion || '8.3'}
|
||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||
>
|
||
<option value="8.4">PHP 8.4</option>
|
||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||
<option value="8.2">PHP 8.2</option>
|
||
<option value="8.1">PHP 8.1</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{form.runtime === 'wordpress' && (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">WordPress Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.runtimeVersion || '6.7'}
|
||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||
>
|
||
<option value="6.7">WordPress 6.7 (Latest)</option>
|
||
<option value="6.6">WordPress 6.6</option>
|
||
<option value="6.5">WordPress 6.5</option>
|
||
<option value="6.4">WordPress 6.4</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.phpVersion || '8.3'}
|
||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||
>
|
||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||
<option value="8.2">PHP 8.2</option>
|
||
<option value="8.1">PHP 8.1</option>
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
{form.runtime === 'go' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Go Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.runtimeVersion || '1.22'}
|
||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||
>
|
||
<option value="1.23">Go 1.23 (Latest)</option>
|
||
<option value="1.22">Go 1.22 (Recommended)</option>
|
||
<option value="1.21">Go 1.21</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{(form.runtime === 'python' || form.runtime === 'django') && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Python Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.runtimeVersion || '3.12'}
|
||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||
>
|
||
<option value="3.13">Python 3.13 (Latest)</option>
|
||
<option value="3.12">Python 3.12 (Recommended)</option>
|
||
<option value="3.11">Python 3.11</option>
|
||
<option value="3.10">Python 3.10</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{form.runtime === 'php' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.phpVersion || '8.3'}
|
||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||
>
|
||
<option value="8.4">PHP 8.4 (Latest)</option>
|
||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||
<option value="8.2">PHP 8.2</option>
|
||
<option value="8.1">PHP 8.1</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
{form.runtime === 'dotnet' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
|
||
<select
|
||
className="input-field"
|
||
value={form.runtimeVersion || '8.0'}
|
||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||
>
|
||
<option value="9.0">.NET 9.0 (Latest)</option>
|
||
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
|
||
<option value="7.0">.NET 7.0</option>
|
||
<option value="6.0">.NET 6.0 LTS</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||
Database
|
||
{form.runtime === 'wordpress' && (
|
||
<span className="text-xs text-blue-500 mr-2"> — WordPress requires MySQL/MariaDB</span>
|
||
)}
|
||
</label>
|
||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3 sm:gap-4">
|
||
{[
|
||
{ value: 'none', label: 'None', icon: <XCircle className="w-6 h-6 text-gray-400" /> },
|
||
{ value: 'postgresql', label: 'PostgreSQL', icon: <svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||
{ value: 'mysql', label: 'MySQL', icon: <svg className="w-6 h-6 text-orange-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||
{ value: 'mariadb', label: 'MariaDB', icon: <svg className="w-6 h-6 text-teal-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
|
||
{ value: 'mongodb', label: 'MongoDB', icon: <svg className="w-6 h-6 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L12 22M12 2C7.58 4 5 8 5 12C5 16 7.58 20 12 22M12 2C16.42 4 19 8 19 12C19 16 16.42 20 12 22"/></svg> },
|
||
].map((opt) => {
|
||
const isWordPress = form.runtime === 'wordpress';
|
||
const disabled = isWordPress && !['mysql', 'mariadb'].includes(opt.value);
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
let dbVersion = '';
|
||
if (opt.value === 'postgresql') dbVersion = '16';
|
||
else if (opt.value === 'mysql') dbVersion = '8.0';
|
||
else if (opt.value === 'mariadb') dbVersion = '11.4';
|
||
else if (opt.value === 'mongodb') dbVersion = '7.0';
|
||
setForm({ ...form, databaseType: opt.value as any, dbVersion });
|
||
}}
|
||
className={`p-4 rounded-xl border-2 text-center transition-colors ${
|
||
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||
>
|
||
<div className="flex justify-center">{opt.icon}</div>
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Database Version — shown when a DB is selected */}
|
||
{form.databaseType !== 'none' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
|
||
</label>
|
||
<select
|
||
className="input-field max-w-xs"
|
||
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
|
||
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })}
|
||
>
|
||
{form.databaseType === 'postgresql' ? (
|
||
<>
|
||
<option value="17">PostgreSQL 17 (Latest)</option>
|
||
<option value="16">PostgreSQL 16 (LTS)</option>
|
||
<option value="15">PostgreSQL 15</option>
|
||
<option value="14">PostgreSQL 14</option>
|
||
</>
|
||
) : form.databaseType === 'mysql' ? (
|
||
<>
|
||
<option value="9.0">MySQL 9.0 (Latest)</option>
|
||
<option value="8.4">MySQL 8.4 (LTS)</option>
|
||
<option value="8.0">MySQL 8.0</option>
|
||
<option value="5.7">MySQL 5.7</option>
|
||
</>
|
||
) : form.databaseType === 'mariadb' ? (
|
||
<>
|
||
<option value="11.4">MariaDB 11.4 (LTS)</option>
|
||
<option value="11.3">MariaDB 11.3</option>
|
||
<option value="10.11">MariaDB 10.11 (LTS)</option>
|
||
<option value="10.6">MariaDB 10.6</option>
|
||
</>
|
||
) : (
|
||
<>
|
||
<option value="7.0">MongoDB 7.0 (Latest)</option>
|
||
<option value="6.0">MongoDB 6.0</option>
|
||
<option value="5.0">MongoDB 5.0</option>
|
||
</>
|
||
)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Database Credentials — shown when a DB is selected */}
|
||
{form.databaseType !== 'none' && (
|
||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<Database className="w-5 h-5 text-blue-500" />
|
||
<h3 className="text-sm font-semibold text-gray-800">Database Credentials</h3>
|
||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Username</label>
|
||
<input
|
||
className="input-field"
|
||
placeholder="appuser"
|
||
value={form.dbUsername || ''}
|
||
onChange={(e) => setForm({ ...form, dbUsername: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
||
<div className="relative">
|
||
<input
|
||
className="input-field pr-20"
|
||
type={showDbPassword ? 'text' : 'password'}
|
||
placeholder="Auto-generated"
|
||
value={form.dbPassword || ''}
|
||
onChange={(e) => setForm({ ...form, dbPassword: e.target.value })}
|
||
/>
|
||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||
let pass = '';
|
||
for (let i = 0; i < 20; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
setForm({ ...form, dbPassword: pass });
|
||
setShowDbPassword(true);
|
||
}}
|
||
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
||
title="Generate random password"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||
>
|
||
{showDbPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400">
|
||
These credentials are used for internal cluster communication only. The database is not exposed externally.
|
||
</p>
|
||
{/* Optional DB dump upload at creation time */}
|
||
<div className="pt-2">
|
||
<label className="block text-xs text-gray-500 mb-2">Optional: Upload DB dump to restore at creation</label>
|
||
<div
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
const f = e.dataTransfer.files[0];
|
||
if (f) {
|
||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||
toast.error('Allowed: .sql, .gz, .dump');
|
||
} else if (f.size > 500 * 1024 * 1024) {
|
||
toast.error('Max 500MB');
|
||
} else {
|
||
setDbDumpFile(f);
|
||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||
}
|
||
}
|
||
}}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onClick={() => dbDumpInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-3 text-center cursor-pointer transition-colors ${dbDumpFile ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'}`}
|
||
>
|
||
<input
|
||
ref={dbDumpInputRef}
|
||
type="file"
|
||
accept=".sql,.gz,.dump"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) {
|
||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||
toast.error('Allowed: .sql, .gz, .dump');
|
||
} else if (f.size > 500 * 1024 * 1024) {
|
||
toast.error('Max 500MB');
|
||
} else {
|
||
setDbDumpFile(f);
|
||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||
}
|
||
}
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
{dbDumpFile ? (
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm text-left">
|
||
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
||
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={() => setDbDumpFile(null)} className="text-sm text-red-500">Remove</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
|
||
<p className="text-xs text-gray-400">Optional • Max 500MB</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Database Storage Size */}
|
||
<div className="pt-2">
|
||
<label className="block text-xs text-gray-500 mb-2">Database Storage Size</label>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||
if (current > 1) setForm({ ...form, dbStorageSize: String(current - 1) });
|
||
}}
|
||
disabled={parseInt(form.dbStorageSize || '1', 10) <= 1}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={form.dbStorageSize || '1'}
|
||
onChange={(e) => {
|
||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
|
||
setForm({ ...form, dbStorageSize: String(val) });
|
||
}}
|
||
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||
if (current < 100) setForm({ ...form, dbStorageSize: String(current + 1) });
|
||
}}
|
||
disabled={parseInt(form.dbStorageSize || '1', 10) >= 100}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||
{dbDumpFile && (
|
||
<span className="text-xs text-blue-500">
|
||
Suggested based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-1 text-xs text-gray-400">Minimum 1GB • Only expansion is allowed after creation</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Optional Services Section */}
|
||
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<Server className="w-5 h-5 text-purple-600" />
|
||
<h3 className="font-semibold text-gray-900">Optional Services</h3>
|
||
<span className="text-xs text-gray-400">(Enable additional services for your app)</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
{/* Redis */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||
form.enableRedis
|
||
? 'border-red-400 bg-red-50 shadow-sm'
|
||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900">Redis</p>
|
||
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
||
</div>
|
||
</div>
|
||
{form.enableRedis && (
|
||
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
|
||
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
{/* RabbitMQ */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||
form.enableRabbitmq
|
||
? 'border-orange-400 bg-orange-50 shadow-sm'
|
||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
|
||
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900">RabbitMQ</p>
|
||
<p className="text-xs text-gray-500">Message broker</p>
|
||
</div>
|
||
</div>
|
||
{form.enableRabbitmq && (
|
||
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
|
||
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
{/* Elasticsearch */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||
form.enableElasticsearch
|
||
? 'border-yellow-400 bg-yellow-50 shadow-sm'
|
||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
|
||
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
||
<p className="text-xs text-gray-500">Logging & search</p>
|
||
</div>
|
||
</div>
|
||
{form.enableElasticsearch && (
|
||
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
|
||
<p>Logs collected via Fluent Bit sidecar</p>
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||
<p className="mt-4 text-xs text-gray-500">
|
||
Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* App Storage Size (all app types) */}
|
||
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<FolderUp className="w-5 h-5 text-green-600" />
|
||
<h3 className="font-semibold text-gray-900">
|
||
{form.runtime === 'wordpress' ? 'Upload Storage (wp-content)'
|
||
: form.runtime === 'laravel' ? 'Storage Directory'
|
||
: 'Application Data Storage'}
|
||
</h3>
|
||
</div>
|
||
<p className="text-sm text-gray-600 mb-4">
|
||
{form.runtime === 'wordpress'
|
||
? 'This space is used for uploads, plugins, themes, and other WordPress files.'
|
||
: form.runtime === 'laravel'
|
||
? 'This space is used for uploads, logs, cache, and other Laravel storage files.'
|
||
: 'This space is used for persistent application data, uploads, and files.'}
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.appStorageSize || '2', 10);
|
||
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
|
||
}}
|
||
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={form.appStorageSize || '2'}
|
||
onChange={(e) => {
|
||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
|
||
setForm({ ...form, appStorageSize: String(val) });
|
||
}}
|
||
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const current = parseInt(form.appStorageSize || '2', 10);
|
||
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
|
||
}}
|
||
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
|
||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||
{wpContentFile && form.runtime === 'wordpress' && (
|
||
<span className="text-xs text-green-600">
|
||
Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-xs text-gray-500">Minimum 1GB • Recommended: 2GB or more</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Resources */}
|
||
{step === 2 && (
|
||
<div className="space-y-6">
|
||
<h2 className="text-lg font-semibold text-gray-900">Resources & Configuration</h2>
|
||
|
||
{/* Cluster Assignment Mode — Admin only */}
|
||
{isAdmin ? (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Assignment</label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setClusterMode('default');
|
||
setForm({ ...form, clusterId: undefined, poolId: undefined });
|
||
}}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
clusterMode === 'default'
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Home className="w-5 h-5 mx-auto text-gray-500" />
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">Default</p>
|
||
<p className="text-xs text-gray-500">Use default cluster</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setClusterMode('manual');
|
||
setForm({ ...form, poolId: undefined });
|
||
}}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
clusterMode === 'manual'
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Target className="w-5 h-5 mx-auto text-gray-500" />
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">Manual</p>
|
||
<p className="text-xs text-gray-500">Pick a specific cluster</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setClusterMode('pool');
|
||
setForm({ ...form, clusterId: undefined });
|
||
}}
|
||
className={`p-3 rounded-xl border-2 text-center transition-colors ${
|
||
clusterMode === 'pool'
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Scale className="w-5 h-5 mx-auto text-gray-500" />
|
||
<p className="mt-1 font-semibold text-sm text-gray-900">Load Balanced</p>
|
||
<p className="text-xs text-gray-500">Pick a cluster pool</p>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Manual: show cluster list */}
|
||
{clusterMode === 'manual' && (
|
||
<div className="space-y-2">
|
||
{clusters.length === 0 ? (
|
||
<p className="text-sm text-gray-400 text-center py-4">No clusters available</p>
|
||
) : (
|
||
clusters.map((cluster) => (
|
||
<button
|
||
key={cluster.id}
|
||
type="button"
|
||
onClick={() => setForm({ ...form, clusterId: cluster.id })}
|
||
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
|
||
form.clusterId === cluster.id
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<Server className="w-5 h-5 text-gray-400" />
|
||
<div>
|
||
<p className="font-semibold text-sm text-gray-900">
|
||
{cluster.name}
|
||
{cluster.isDefault && (
|
||
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">Default</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-gray-500">
|
||
{[cluster.provider, cluster.region].filter(Boolean).join(' · ') || 'No region info'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||
cluster.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||
}`}>
|
||
{cluster.status}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pool: show pool list */}
|
||
{clusterMode === 'pool' && (
|
||
<div className="space-y-2">
|
||
{pools.length === 0 ? (
|
||
<div className="text-center py-4">
|
||
<p className="text-sm text-gray-400">No cluster pools configured</p>
|
||
<p className="text-xs text-gray-400 mt-1">Ask your admin to create a cluster pool</p>
|
||
</div>
|
||
) : (
|
||
pools.map((pool) => (
|
||
<button
|
||
key={pool.id}
|
||
type="button"
|
||
onClick={() => setForm({ ...form, poolId: pool.id })}
|
||
className={`w-full p-3 rounded-xl border-2 text-left transition-colors ${
|
||
form.poolId === pool.id
|
||
? 'border-primary-500 bg-primary-50'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<Scale className="w-5 h-5 text-gray-400" />
|
||
<div>
|
||
<p className="font-semibold text-sm text-gray-900">{pool.name}</p>
|
||
{pool.description && (
|
||
<p className="text-xs text-gray-500">{pool.description}</p>
|
||
)}
|
||
<div className="flex items-center space-x-2 mt-1">
|
||
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||
{pool.strategy === 'least-apps' ? <><BarChart3 className="w-3 h-3" /> Least Apps</> : <><RotateCw className="w-3 h-3" /> Round Robin</>}
|
||
</span>
|
||
<span className="text-xs text-gray-400">
|
||
{pool.clusters.length} cluster{pool.clusters.length !== 1 ? 's' : ''}:
|
||
{' '}{pool.clusters.map((c) => c.name).join(', ')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Default: info text */}
|
||
{clusterMode === 'default' && (
|
||
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||
<div className="flex items-center space-x-3">
|
||
<Home className="w-5 h-5 text-gray-400" />
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-700">Default cluster will be used</p>
|
||
<p className="text-xs text-gray-500">
|
||
Your app will be deployed to the platform's default cluster
|
||
{clusters.find((c) => c.isDefault) && (
|
||
<> — <strong>{clusters.find((c) => c.isDefault)?.name}</strong></>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||
<div className="flex items-center space-x-3">
|
||
<Home className="w-5 h-5 text-gray-400" />
|
||
<div>
|
||
<p className="text-sm font-medium text-gray-700">Cluster Assignment</p>
|
||
<p className="text-xs text-gray-500">
|
||
Your app will be automatically deployed to the platform's default cluster
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
|
||
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
|
||
<option value="50m">50m (0.05 core)</option>
|
||
<option value="100m">100m (0.1 core)</option>
|
||
<option value="250m">250m (0.25 core)</option>
|
||
<option value="500m">500m (0.5 core)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
|
||
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}>
|
||
<option value="250m">250m (0.25 core)</option>
|
||
<option value="500m">500m (0.5 core)</option>
|
||
<option value="1">1 core</option>
|
||
<option value="2">2 cores</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
|
||
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}>
|
||
<option value="64Mi">64 Mi</option>
|
||
<option value="128Mi">128 Mi</option>
|
||
<option value="256Mi">256 Mi</option>
|
||
<option value="512Mi">512 Mi</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Limit</label>
|
||
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}>
|
||
<option value="256Mi">256 Mi</option>
|
||
<option value="512Mi">512 Mi</option>
|
||
<option value="1Gi">1 Gi</option>
|
||
<option value="2Gi">2 Gi</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Replicas</label>
|
||
<input
|
||
type="number"
|
||
className="input-field"
|
||
min={1}
|
||
max={10}
|
||
value={form.replicas}
|
||
onChange={(e) => setForm({ ...form, replicas: parseInt(e.target.value) || 1 })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Port</label>
|
||
<input
|
||
type="number"
|
||
className="input-field"
|
||
value={form.port}
|
||
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 3000 })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Environment Variables */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
|
||
<div className="flex flex-col sm:flex-row gap-2 mb-3">
|
||
<input
|
||
className="input-field flex-1"
|
||
placeholder="KEY"
|
||
value={envKey}
|
||
onChange={(e) => setEnvKey(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input-field flex-1"
|
||
placeholder="value"
|
||
value={envVal}
|
||
onChange={(e) => setEnvVal(e.target.value)}
|
||
/>
|
||
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
|
||
</div>
|
||
{Object.entries(form.envVars || {}).map(([key, value]) => (
|
||
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
|
||
<span className="text-sm font-mono">
|
||
<strong>{key}</strong> = {value}
|
||
</span>
|
||
<button onClick={() => removeEnvVar(key)} className="text-red-500 text-sm">Remove</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Review */}
|
||
{step === 3 && (
|
||
<div className="space-y-4">
|
||
<h2 className="text-lg font-semibold text-gray-900">Review & Deploy</h2>
|
||
<div className="bg-gray-50 rounded-xl p-5 sm:p-6 space-y-3">
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Name</span>
|
||
<span className="text-sm font-medium">{form.name}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Runtime</span>
|
||
<span className="text-sm font-medium">
|
||
{form.runtime}
|
||
{form.runtime === 'nodejs' && form.runtimeVersion ? ` v${form.runtimeVersion}` : ''}
|
||
{form.runtime === 'wordpress' && form.runtimeVersion ? ` v${form.runtimeVersion}` : ''}
|
||
{(form.runtime === 'laravel' || form.runtime === 'wordpress') && form.phpVersion ? ` — PHP ${form.phpVersion}` : ''}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Database</span>
|
||
<span className="text-sm font-medium">
|
||
{form.databaseType}
|
||
{form.databaseType !== 'none' && form.dbVersion ? ` v${form.dbVersion}` : ''}
|
||
</span>
|
||
</div>
|
||
{form.databaseType !== 'none' && (
|
||
<>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">DB Username</span>
|
||
<span className="text-sm font-medium">{form.dbUsername || 'appuser (default)'}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">DB Password</span>
|
||
<span className="text-sm font-medium">{form.dbPassword ? '••••••••' : 'Auto-generated'}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">DB Storage</span>
|
||
<span className="text-sm font-medium">{form.dbStorageSize || '1'} GB</span>
|
||
</div>
|
||
{dbDumpFile && (
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">DB Dump</span>
|
||
<span className="text-sm font-medium">{dbDumpFile.name} ({(dbDumpFile.size / (1024 * 1024)).toFixed(1)} MB)</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Source</span>
|
||
<span className="text-sm font-medium">
|
||
{form.runtime === 'wordpress'
|
||
? wpMode === 'migrate' && wpContentFile
|
||
? `Migrate: ${wpContentFile.name} (${(wpContentFile.size / (1024 * 1024)).toFixed(1)} MB)`
|
||
: 'WordPress (Fresh Install)'
|
||
: sourceMethod === 'upload'
|
||
? zipFile
|
||
? zipFile.name
|
||
: '—'
|
||
: form.gitUrl || '—'}
|
||
</span>
|
||
</div>
|
||
{form.runtime !== 'wordpress' && sourceMethod === 'git' && form.gitBranch && (
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Branch</span>
|
||
<span className="text-sm font-medium">{form.gitBranch}</span>
|
||
</div>
|
||
)}
|
||
{form.runtime !== 'wordpress' && sourceMethod === 'git' && form.gitToken && (
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Auth</span>
|
||
<span className="text-sm font-medium text-green-600">Token provided</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Cluster</span>
|
||
<span className="text-sm font-medium">
|
||
{isAdmin && clusterMode === 'manual' && form.clusterId
|
||
? `${clusters.find((c) => c.id === form.clusterId)?.name || form.clusterId}`
|
||
: isAdmin && clusterMode === 'pool' && form.poolId
|
||
? `${pools.find((p) => p.id === form.poolId)?.name || 'Pool'} (Load Balanced)`
|
||
: 'Default Cluster'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">CPU</span>
|
||
<span className="text-sm font-medium">{form.cpuRequest} / {form.cpuLimit}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Memory</span>
|
||
<span className="text-sm font-medium">{form.memoryRequest} / {form.memoryLimit}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Replicas</span>
|
||
<span className="text-sm font-medium">{form.replicas}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Port</span>
|
||
<span className="text-sm font-medium">{form.port}</span>
|
||
</div>
|
||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Optional Services</span>
|
||
<span className="text-sm font-medium">
|
||
{[
|
||
form.enableRedis && 'Redis',
|
||
form.enableRabbitmq && 'RabbitMQ',
|
||
form.enableElasticsearch && 'Elasticsearch',
|
||
].filter(Boolean).join(', ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{Object.keys(form.envVars || {}).length > 0 && (
|
||
<div className="flex justify-between">
|
||
<span className="text-sm text-gray-500">Env Vars</span>
|
||
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cost Breakdown */}
|
||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl p-5 sm:p-6 border border-emerald-200">
|
||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
|
||
<DollarSign className="w-4 h-4 text-emerald-600" /> Cost Estimate
|
||
</h3>
|
||
{costLoading ? (
|
||
<div className="text-sm text-gray-400 text-center py-3">Calculating...</div>
|
||
) : costData && costData.monthly > 0 ? (
|
||
<div className="space-y-3">
|
||
{/* Billing cycle selector */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{(['hourly', 'monthly', 'yearly'] as BillingCycle[]).map((cycle) => (
|
||
<button
|
||
key={cycle}
|
||
type="button"
|
||
onClick={() => setSelectedCycle(cycle)}
|
||
className={`rounded-lg p-3 text-center transition-all ${
|
||
selectedCycle === cycle
|
||
? 'bg-white ring-2 ring-emerald-400 shadow-md'
|
||
: 'bg-white/60 hover:bg-white'
|
||
}`}
|
||
>
|
||
<p className="text-xs text-gray-500">
|
||
{cycle === 'hourly' ? 'Hourly' : cycle === 'monthly' ? 'Monthly' : 'Yearly'}
|
||
</p>
|
||
<p className="text-lg font-bold text-emerald-700">
|
||
{Number(costData[cycle]).toLocaleString('en-US')}
|
||
</p>
|
||
<p className="text-xs text-gray-400">Toman</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{costData.breakdown && costData.breakdown.length > 0 && (
|
||
<div className="mt-2 pt-3 border-t border-emerald-200/50">
|
||
<p className="text-xs font-medium text-gray-500 mb-2">Breakdown</p>
|
||
{costData.breakdown.map((item, i) => (
|
||
<div key={i} className="flex justify-between text-xs py-1">
|
||
<span className="text-gray-600">{item.label}</span>
|
||
<span className="text-gray-900 font-medium">
|
||
{Number(item[selectedCycle]).toLocaleString('en-US')} T/{selectedCycle === 'hourly' ? 'hr' : selectedCycle === 'monthly' ? 'mo' : 'yr'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-gray-400 text-center py-3">No pricing plans defined yet</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Payment Method */}
|
||
{costData && costData.monthly > 0 && (
|
||
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Payment Method</h3>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPaymentMethod('wallet')}
|
||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<Wallet className="w-5 h-5 text-primary-600" />
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay from Wallet</p>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
Balance: {Number(walletBalance).toLocaleString('en-US')} T
|
||
{!hasEnoughBalance && <span className="text-red-500 block mt-0.5">Insufficient balance</span>}
|
||
</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPaymentMethod('gateway')}
|
||
className={`p-4 rounded-xl border-2 text-left transition-colors ${
|
||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||
<p className="mt-2 font-semibold text-sm text-gray-900">Pay Now</p>
|
||
<p className="text-xs text-gray-500 mt-1">Online payment gateway</p>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 p-3 bg-gray-50 rounded-lg flex items-center justify-between">
|
||
<span className="text-sm text-gray-600">Amount to pay ({selectedCycle})</span>
|
||
<span className="text-lg font-bold text-gray-900">{Number(payAmount).toLocaleString('en-US')} Toman</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Navigation */}
|
||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||
<button
|
||
onClick={() => setStep(step - 1)}
|
||
disabled={step === 0}
|
||
className="btn-ghost disabled:opacity-0 disabled:pointer-events-none"
|
||
>
|
||
← Back
|
||
</button>
|
||
{step < steps.length - 1 ? (
|
||
<button
|
||
onClick={() => setStep(step + 1)}
|
||
disabled={!canNext()}
|
||
className="btn-primary disabled:opacity-50"
|
||
>
|
||
Next →
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => {
|
||
if (!costData || costData.monthly === 0) {
|
||
// No pricing — deploy directly
|
||
handleSubmit();
|
||
} else if (paymentMethod === 'wallet') {
|
||
if (!hasEnoughBalance) {
|
||
toast.error('Insufficient wallet balance. Please top up or use payment gateway.');
|
||
return;
|
||
}
|
||
walletPayMutation.mutate();
|
||
} else {
|
||
gatewayPayMutation.mutate();
|
||
}
|
||
}}
|
||
disabled={createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending}
|
||
className="btn-primary disabled:opacity-50"
|
||
>
|
||
{(createMutation.isPending || walletPayMutation.isPending || gatewayPayMutation.isPending)
|
||
? uploadProgress > 0 && uploadProgress < 100
|
||
? `Uploading... ${uploadProgress}%`
|
||
: 'Processing...'
|
||
: costData && costData.monthly > 0
|
||
? <><CreditCard className="w-4 h-4 inline" /> Pay & Deploy</>
|
||
: <><Rocket className="w-4 h-4 inline" /> Deploy Application</>}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Deploy Progress Overlay */}
|
||
{deployStage !== 'idle' && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-6">
|
||
<div className="text-center">
|
||
{deployStage === 'error' ? (
|
||
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||
) : deployStage === 'done' ? (
|
||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
|
||
) : (
|
||
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
|
||
)}
|
||
<h3 className="text-lg font-semibold text-gray-900">
|
||
{deployStage === 'error' ? 'Deployment Failed' : deployStage === 'done' ? 'Success!' : 'Deploying Application'}
|
||
</h3>
|
||
<p className="text-sm text-gray-500 mt-1">{stageLabels[deployStage]}</p>
|
||
</div>
|
||
|
||
{/* Stage Progress Steps */}
|
||
<div className="space-y-3">
|
||
{/* Creating */}
|
||
<div className="flex items-center gap-3">
|
||
{deployStage === 'creating' ? (
|
||
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
|
||
) : ['uploading-source', 'uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
|
||
) : deployStage === 'error' ? (
|
||
<XCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||
) : (
|
||
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
|
||
)}
|
||
<span className={`text-sm ${deployStage === 'creating' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
|
||
Creating application
|
||
</span>
|
||
</div>
|
||
|
||
{/* Uploading source (only if we have a file) */}
|
||
{((form.runtime === 'wordpress' && (wpMode === 'migrate' || wpMode === 'public_html') && wpContentFile) || (form.runtime !== 'wordpress' && sourceMethod === 'upload' && zipFile)) && (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-3">
|
||
{deployStage === 'uploading-source' ? (
|
||
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
|
||
) : ['uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
|
||
) : (
|
||
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
|
||
)}
|
||
<span className={`text-sm flex-1 ${deployStage === 'uploading-source' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
|
||
Uploading source code
|
||
{deployStage === 'uploading-source' && uploadProgress > 0 && (
|
||
<span className="text-primary-600 font-semibold ml-2">{uploadProgress}%</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{deployStage === 'uploading-source' && (
|
||
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
|
||
style={{ width: `${uploadProgress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Uploading DB dump (only if we have a dump) */}
|
||
{dbDumpFile && form.databaseType !== 'none' && (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-3">
|
||
{deployStage === 'uploading-db' ? (
|
||
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
|
||
) : ['paying', 'deploying', 'done'].includes(deployStage) ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
|
||
) : (
|
||
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
|
||
)}
|
||
<span className={`text-sm flex-1 ${deployStage === 'uploading-db' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
|
||
Uploading database dump
|
||
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
||
<span className="text-primary-600 font-semibold ml-2">{dbUploadProgress}%</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{deployStage === 'uploading-db' && (
|
||
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
|
||
style={{ width: `${dbUploadProgress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Payment (only if cost > 0) */}
|
||
{costData && costData.monthly > 0 && (
|
||
<div className="flex items-center gap-3">
|
||
{deployStage === 'paying' ? (
|
||
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
|
||
) : ['deploying', 'done'].includes(deployStage) ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
|
||
) : (
|
||
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
|
||
)}
|
||
<span className={`text-sm ${deployStage === 'paying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
|
||
Processing payment
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Deploying */}
|
||
<div className="flex items-center gap-3">
|
||
{deployStage === 'deploying' ? (
|
||
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
|
||
) : deployStage === 'done' ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
|
||
) : (
|
||
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
|
||
)}
|
||
<span className={`text-sm ${deployStage === 'deploying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
|
||
Starting deployment
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|