init
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Cluster } from '@/types';
|
||||
|
||||
export default function AdminClustersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
apiServer: '',
|
||||
kubeconfig: '',
|
||||
region: '',
|
||||
provider: '',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
|
||||
queryKey: ['admin-clusters'],
|
||||
queryFn: () => api.get('/clusters').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: typeof form) => api.post('/clusters', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
toast.success('Cluster added & connection verified ✓');
|
||||
setShowForm(false);
|
||||
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', isDefault: false });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const message = err?.response?.data?.message || 'Failed to add cluster';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
setTestingId(id);
|
||||
return api.post(`/clusters/${id}/test`);
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
const data = res.data;
|
||||
if (data.connected) {
|
||||
toast.success(`Connection OK — Kubernetes ${data.version}`);
|
||||
} else {
|
||||
toast.error(`Connection failed: ${data.error}`);
|
||||
}
|
||||
setTestingId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to test connection');
|
||||
setTestingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
toast.success('Cluster removed');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Cluster Management</h1>
|
||||
<button onClick={() => setShowForm(!showForm)} className="btn-primary">
|
||||
{showForm ? 'Cancel' : '+ Add Cluster'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold">Register New Cluster</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
The system will verify the Kubernetes connection before registering. Only clusters with valid kubeconfig will be marked as active.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
|
||||
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
||||
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
||||
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="">Select provider</option>
|
||||
<option value="aws">AWS (EKS)</option>
|
||||
<option value="gcp">GCP (GKE)</option>
|
||||
<option value="azure">Azure (AKS)</option>
|
||||
<option value="bare-metal">Bare Metal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
|
||||
<textarea
|
||||
className="input-field font-mono text-xs"
|
||||
rows={8}
|
||||
placeholder="Paste your kubeconfig here..."
|
||||
value={form.kubeconfig}
|
||||
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createMutation.mutate(form)}
|
||||
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{createMutation.isPending ? '🔄 Verifying connection & adding...' : 'Add Cluster'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading clusters...</div>
|
||||
) : clusters.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500">No clusters registered yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div key={cluster.id} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
cluster.status === 'active' ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}>
|
||||
{cluster.status === 'active' ? '✅' : '❌'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
||||
{cluster.isDefault && (
|
||||
<span className="px-2 py-0.5 bg-primary-100 text-primary-700 text-xs rounded-full font-medium">Default</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cluster.status === 'active' ? 'bg-green-100 text-green-700'
|
||||
: cluster.status === 'maintenance' ? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cluster.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
className="text-sm px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testingId === cluster.id ? '🔄 Testing...' : '🔌 Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove this cluster?')) deleteMutation.mutate(cluster.id); }}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { User } from '@/types';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => api.get('/users').then((r) => r.data),
|
||||
});
|
||||
|
||||
const toggleActive = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('User updated');
|
||||
},
|
||||
});
|
||||
|
||||
const changeRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||
api.patch(`/users/${id}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('Role updated');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card text-center py-12 text-gray-500">Loading users...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Role</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Namespace</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
className="text-sm border border-gray-300 rounded px-2 py-1"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 font-mono">{user.namespace || '—'}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Application, Deployment } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export default function AppDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const appId = params.id as string;
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const logsEndRef = useRef<HTMLPreElement>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||
refetchInterval: 5000, // Poll for status updates
|
||||
});
|
||||
|
||||
const { data: logsData } = useQuery<{ logs: string }>({
|
||||
queryKey: ['logs', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
||||
enabled: showLogs,
|
||||
refetchInterval: showLogs ? 3000 : false,
|
||||
});
|
||||
|
||||
// Auto-scroll logs to bottom
|
||||
useEffect(() => {
|
||||
if (logsEndRef.current) {
|
||||
logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsData]);
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
};
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Deployment triggered!');
|
||||
},
|
||||
onError: () => toast.error('Failed to trigger deployment'),
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/stop`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Application stopped');
|
||||
},
|
||||
onError: () => toast.error('Failed to stop application'),
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Application started');
|
||||
},
|
||||
onError: () => toast.error('Failed to start application'),
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Application restarting...');
|
||||
},
|
||||
onError: () => toast.error('Failed to restart application'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${appId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
toast.success('Application deleted');
|
||||
router.push('/dashboard/apps');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return 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));
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
toast.success('Source code uploaded successfully!');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to upload source code');
|
||||
setUploadProgress(0);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileUpload = useCallback((file: File) => {
|
||||
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
|
||||
toast.error('Please upload a .zip or .tar.gz file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 100MB');
|
||||
return;
|
||||
}
|
||||
uploadMutation.mutate(file);
|
||||
}, [uploadMutation]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}, [handleFileUpload]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
if (isLoading || !app) {
|
||||
return <div className="card text-center py-12 text-gray-500">Loading...</div>;
|
||||
}
|
||||
|
||||
const latestStatus = deployments[0]?.status || 'pending';
|
||||
const hasDeployments = deployments.length > 0;
|
||||
const isStopped = latestStatus === 'stopped';
|
||||
const isRunning = latestStatus === 'running';
|
||||
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Are you sure you want to delete "${app.name}"?\n\nThis will permanently remove:\n• All Kubernetes resources (pods, services, ingress)\n• Database and volumes\n• All deployment records\n• Uploaded source code`)) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center text-2xl">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{app.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{app.runtime} · {app.subdomain}.apps.cloudhost.local
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
{/* Only show deploy button if NEVER deployed before */}
|
||||
{!hasDeployments && (
|
||||
<button
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{deployMutation.isPending ? '⏳ Deploying...' : '🚀 Deploy'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* After first deploy: show start/stop/restart */}
|
||||
{hasDeployments && (
|
||||
<>
|
||||
{isStopped ? (
|
||||
<button
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={startMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{startMutation.isPending ? '⏳ Starting...' : '▶️ Start'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={stopMutation.isPending || isInProgress}
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
>
|
||||
{stopMutation.isPending ? '⏳ Stopping...' : '⏹️ Stop'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending}
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
>
|
||||
{restartMutation.isPending ? '⏳...' : '🔄 Restart'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 border border-red-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleteMutation.isPending ? '⏳ Deleting...' : '�️ Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & Config */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration</h2>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Runtime</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.runtime}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Database</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.databaseType}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Replicas</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.replicas}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">CPU</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.cpuRequest} / {app.cpuLimit}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Memory</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.memoryRequest} / {app.memoryLimit}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Port</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
|
||||
</div>
|
||||
{app.latestImageTag && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Image</dt>
|
||||
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
|
||||
{app.latestImageTag}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
|
||||
{deployments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<p className="text-gray-500 text-sm">No deployments yet</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{deployments.slice(0, 10).map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{d.version || d.imageTag}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(d.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{d.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1 truncate max-w-[250px]" title={d.errorMessage}>
|
||||
❌ {d.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColors[d.status] || 'bg-gray-100'}`}>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Code Upload */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">📦 Source Code</h2>
|
||||
|
||||
{app.codePath ? (
|
||||
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4">
|
||||
<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">Source code uploaded</p>
|
||||
<p className="text-xs text-green-600">{app.codePath.split('/').pop()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-sm text-green-700 hover:text-green-900 font-medium"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
) : app.gitUrl ? (
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
|
||||
🔗
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Git repository connected</p>
|
||||
<p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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'
|
||||
}
|
||||
${uploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,.tar.gz,.tgz"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
{uploadMutation.isPending ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-3xl">⏳</div>
|
||||
<p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p>
|
||||
<div className="w-48 mx-auto bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-3xl">📁</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{app.codePath ? 'Upload new version' : 'Upload your project source code'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Drag & drop a <strong>.zip</strong> file here, or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Max size: 100MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pod Logs */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">📋 Pod Logs</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{showLogs && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>Live (every 3s)</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
{showLogs ? '🔽 Hide Logs' : '📋 Show Logs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showLogs && (
|
||||
<pre
|
||||
ref={logsEndRef}
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Application } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export default function AppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
toast.success('Application deleted');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card text-center py-12 text-gray-500">Loading applications...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Applications</h1>
|
||||
<Link href="/dashboard/deploy" className="btn-primary">
|
||||
+ New Application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||
Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Runtime</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Database</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Replicas</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<tr key={app.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="font-medium text-primary-600 hover:text-primary-800">
|
||||
{app.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.runtime}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.databaseType}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[latestStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="text-sm text-primary-600 hover:text-primary-800">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this application?')) deleteMutation.mutate(app.id); }}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
const userNavItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/dashboard/apps', label: 'Applications', icon: '📦' },
|
||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: '🚀' },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: '/dashboard/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: '🖥️' },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/dashboard" className="text-xl font-bold text-primary-600">
|
||||
☁️ CloudHost
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{user?.firstName} {user?.lastName}
|
||||
{user?.role === 'admin' && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-medium">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { logout(); router.push('/login'); }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-56 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
{userNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="pt-4 pb-2">
|
||||
<p className="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Admin
|
||||
</p>
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
pathname === item.href
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Application } from '@/types';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
stopped: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
});
|
||||
|
||||
const runningApps = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'running'),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Welcome back, {user?.firstName}! 👋
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Here's an overview of your applications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Total Apps</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">{apps.length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Running</p>
|
||||
<p className="mt-2 text-3xl font-bold text-green-600">{runningApps.length}</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium text-gray-500">Deployments (7d)</p>
|
||||
<p className="mt-2 text-3xl font-bold text-primary-600">
|
||||
{apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Applications</h2>
|
||||
<Link href="/dashboard/deploy" className="btn-primary text-sm">
|
||||
+ New Application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card text-center py-12 text-gray-500">Loading...</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-500 text-lg">No applications yet</p>
|
||||
<p className="text-gray-400 mt-2">
|
||||
Deploy your first application to get started.
|
||||
</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-block">
|
||||
Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{apps.slice(0, 5).map((app) => {
|
||||
const latestDeploy = app.deployments?.[0];
|
||||
const status = latestDeploy?.status || 'pending';
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card hover:border-primary-300 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-100 flex items-center justify-center text-lg">
|
||||
{app.runtime === 'nodejs' ? '🟩' : '🟧'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
statusColors[status] || 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user