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
@@ -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>
);
}
+107
View File
@@ -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>
);
}
+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>
);
}
+123
View File
@@ -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>
);
}
+119
View File
@@ -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&apos;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>
);
}