This commit is contained in:
keyhan
2026-04-05 15:22:01 +03:30
commit 33be1649c4
82 changed files with 23956 additions and 0 deletions
+517
View File
@@ -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>
);
}