init
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { CreateApplicationDto } from '@/types';
|
||||
|
||||
const steps = ['Basic Info', 'Runtime & Database', 'Resources', 'Review'];
|
||||
|
||||
export default function DeployPage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState<CreateApplicationDto>({
|
||||
name: '',
|
||||
description: '',
|
||||
runtime: 'nodejs',
|
||||
databaseType: 'none',
|
||||
gitUrl: '',
|
||||
envVars: {},
|
||||
cpuRequest: '100m',
|
||||
cpuLimit: '500m',
|
||||
memoryRequest: '128Mi',
|
||||
memoryLimit: '512Mi',
|
||||
replicas: 1,
|
||||
port: 3000,
|
||||
});
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: CreateApplicationDto) => {
|
||||
const res = await api.post('/applications', data);
|
||||
const appId = res.data.id;
|
||||
|
||||
// Upload zip file if selected
|
||||
if (sourceMethod === 'upload' && zipFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
toast.success('Application created! Triggering deployment...');
|
||||
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
|
||||
router.push(`/dashboard/apps/${res.data.id}`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to create application');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
createMutation.mutate(form);
|
||||
};
|
||||
|
||||
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 canNext = () => {
|
||||
if (step === 0) {
|
||||
if (form.name.length < 2) return false;
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Deploy New Application</h1>
|
||||
<p className="mt-1 text-gray-500">Follow the steps to deploy your app to the cloud.</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} className="flex items-center">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||
i <= step ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className={`ml-2 text-sm ${i <= step ? 'text-gray-900 font-medium' : 'text-gray-400'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{i < steps.length - 1 && <div className="w-8 h-0.5 bg-gray-200 mx-3" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{/* Step 0: Basic Info */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">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>
|
||||
|
||||
{/* Source Code Method */}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">📁</span>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">🔗</span>
|
||||
<p className="mt-1 font-semibold text-sm text-gray-900">Git Repository</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sourceMethod === 'git' ? (
|
||||
<div>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
value={form.gitUrl}
|
||||
onChange={(e) => setForm({ ...form, gitUrl: e.target.value })}
|
||||
/>
|
||||
</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">
|
||||
✅
|
||||
</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">
|
||||
<div className="text-3xl">📦</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Runtime & Database */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Runtime & Database</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Application Runtime</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ value: 'nodejs', label: 'Node.js', icon: '🟩', desc: 'Express, NestJS, Fastify...' },
|
||||
{ value: 'laravel', label: 'Laravel', icon: '🟧', desc: 'PHP 8.3, Composer, Artisan' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, runtime: opt.value as any, port: opt.value === 'nodejs' ? 3000 : 8000 })}
|
||||
className={`p-4 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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{opt.icon}</span>
|
||||
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
|
||||
<p className="text-xs text-gray-500">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Database</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ value: 'none', label: 'None', icon: '❌' },
|
||||
{ value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
|
||||
{ value: 'mysql', label: 'MySQL', icon: '🐬' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, databaseType: opt.value as any })}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{opt.icon}</span>
|
||||
<p className="mt-2 font-semibold text-sm text-gray-900">{opt.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Resources */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Resources & Configuration</h2>
|
||||
<div className="grid 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-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 space-x-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">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">Review & Deploy</h2>
|
||||
<div className="bg-gray-50 rounded-xl 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}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Database</span>
|
||||
<span className="text-sm font-medium">{form.databaseType}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Source</span>
|
||||
<span className="text-sm font-medium">
|
||||
{sourceMethod === 'upload'
|
||||
? zipFile
|
||||
? `📁 ${zipFile.name}`
|
||||
: '—'
|
||||
: form.gitUrl || '—'}
|
||||
</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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setStep(step - 1)}
|
||||
disabled={step === 0}
|
||||
className="btn-secondary disabled:opacity-30"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{step < steps.length - 1 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={!canNext()}
|
||||
className="btn-primary"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={createMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{createMutation.isPending
|
||||
? uploadProgress > 0 && uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: 'Deploying...'
|
||||
: '🚀 Deploy Application'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user