3c3e0e48fa
- Replaced react-hot-toast with react-toastify (ToastContainer in providers.tsx) - Removed @heroicons/react dependency - Added lucide-react for consistent SVG icon system - Migrated all 18 frontend pages from emoji icons to Lucide components - Updated sidebar navigation, login/register, dashboard, apps, deploy, tickets, admin users/clusters/pools pages - All emoji indicators (status, runtime, actions) now use proper SVG icons - Build passes with zero TypeScript errors
863 lines
39 KiB
TypeScript
863 lines
39 KiB
TypeScript
'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-toastify';
|
||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic } from '@/types';
|
||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin } from 'lucide-react';
|
||
|
||
const statusColors: Record<string, string> = {
|
||
running: 'badge-green',
|
||
pending: 'badge-yellow',
|
||
building: 'badge-blue',
|
||
deploying: 'badge-blue',
|
||
failed: 'badge-red',
|
||
build_failed: 'badge-red',
|
||
stopped: 'badge-gray',
|
||
};
|
||
|
||
/**
|
||
* Parse CPU value to millicores (e.g. "100m" → 100, "1" → 1000, "250n" → 0.00025)
|
||
*/
|
||
function parseCpuToMillicores(cpu: string): number {
|
||
if (!cpu || cpu === '0') return 0;
|
||
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;
|
||
if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000;
|
||
if (cpu.endsWith('m')) return parseFloat(cpu);
|
||
return parseFloat(cpu) * 1000;
|
||
}
|
||
|
||
/**
|
||
* Parse memory value to MiB (e.g. "128Mi" → 128, "1Gi" → 1024, "131072Ki" → 128)
|
||
*/
|
||
function parseMemoryToMi(memory: string): number {
|
||
if (!memory || memory === '0') return 0;
|
||
if (memory.endsWith('Ki')) return parseFloat(memory) / 1024;
|
||
if (memory.endsWith('Mi')) return parseFloat(memory);
|
||
if (memory.endsWith('Gi')) return parseFloat(memory) * 1024;
|
||
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
|
||
// raw bytes
|
||
return parseFloat(memory) / (1024 * 1024);
|
||
}
|
||
|
||
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 [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const logsEndRef = useRef<HTMLPreElement>(null);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [showResources, setShowResources] = useState(false);
|
||
const [resourceForm, setResourceForm] = useState({
|
||
cpuRequest: '',
|
||
cpuLimit: '',
|
||
memoryRequest: '',
|
||
memoryLimit: '',
|
||
replicas: 1,
|
||
});
|
||
|
||
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 && logTab === 'pod',
|
||
refetchInterval: showLogs && logTab === 'pod' ? 3000 : false,
|
||
});
|
||
|
||
const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({
|
||
queryKey: ['build-logs', appId],
|
||
queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data),
|
||
enabled: showLogs && logTab === 'build',
|
||
refetchInterval: showLogs && logTab === 'build' ? 5000 : false,
|
||
});
|
||
|
||
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
|
||
queryKey: ['resources', appId],
|
||
queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data),
|
||
enabled: showResources,
|
||
refetchInterval: showResources ? 5000 : false,
|
||
});
|
||
|
||
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
|
||
queryKey: ['clusters-public'],
|
||
queryFn: () => api.get('/clusters/public').then((r) => r.data),
|
||
});
|
||
|
||
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
|
||
queryKey: ['pools-public'],
|
||
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
|
||
});
|
||
|
||
// Sync form when resource data loads
|
||
useEffect(() => {
|
||
if (resourceUsage?.configured) {
|
||
setResourceForm({
|
||
cpuRequest: resourceUsage.configured.cpuRequest,
|
||
cpuLimit: resourceUsage.configured.cpuLimit,
|
||
memoryRequest: resourceUsage.configured.memoryRequest,
|
||
memoryLimit: resourceUsage.configured.memoryLimit,
|
||
replicas: resourceUsage.configured.replicas,
|
||
});
|
||
}
|
||
}, [resourceUsage?.configured]);
|
||
|
||
// 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 redeployMutation = useMutation({
|
||
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
|
||
onSuccess: () => {
|
||
invalidateAll();
|
||
toast.success('Redeploy triggered — building new version from latest source');
|
||
},
|
||
onError: () => toast.error('Failed to trigger redeploy'),
|
||
});
|
||
|
||
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 scaleMutation = useMutation({
|
||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||
api.patch(`/applications/${appId}/resources`, data),
|
||
onSuccess: () => {
|
||
invalidateAll();
|
||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||
toast.success('Resources updated successfully!');
|
||
},
|
||
onError: () => toast.error('Failed to update resources'),
|
||
});
|
||
|
||
const previewMutation = useMutation({
|
||
mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data),
|
||
onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => {
|
||
// Open the preview URL in a new tab
|
||
window.open(data.url, '_blank');
|
||
toast.success(`Preview opened on port ${data.nodePort}`);
|
||
},
|
||
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||
});
|
||
|
||
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="space-y-6 animate-fade-in">
|
||
<div className="flex items-center gap-4">
|
||
<div className="skeleton w-14 h-14 rounded-2xl" />
|
||
<div className="space-y-2 flex-1">
|
||
<div className="skeleton h-6 w-48" />
|
||
<div className="skeleton h-4 w-72" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div className="card space-y-3">
|
||
{[1,2,3,4,5].map(i => <div key={i} className="skeleton h-4 w-full" />)}
|
||
</div>
|
||
<div className="card space-y-3">
|
||
{[1,2,3].map(i => <div key={i} className="skeleton h-12 w-full rounded-lg" />)}
|
||
</div>
|
||
</div>
|
||
</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 animate-fade-in">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center shrink-0">
|
||
<Hexagon className={`w-7 h-7 ${app.runtime === 'nodejs' ? 'text-green-500' : 'text-orange-500'}`} />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 truncate">{app.name}</h1>
|
||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||
{latestStatus}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-gray-500 truncate">
|
||
{app.runtime} · {app.subdomain}.apps.cloudhost.local
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2 shrink-0">
|
||
{!hasDeployments && (
|
||
<button onClick={() => deployMutation.mutate()} disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)} className="btn-primary text-sm disabled:opacity-50">
|
||
{deployMutation.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Deploying...</> : <><Rocket className="w-4 h-4 inline" /> Deploy</>}
|
||
</button>
|
||
)}
|
||
{hasDeployments && (
|
||
<>
|
||
{isStopped ? (
|
||
<button onClick={() => startMutation.mutate()} disabled={startMutation.isPending} className="btn-primary text-sm disabled:opacity-50">
|
||
{startMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Play className="w-3 h-3 inline" /> Start</>}
|
||
</button>
|
||
) : (
|
||
<button onClick={() => stopMutation.mutate()} disabled={stopMutation.isPending || isInProgress} className="btn-secondary text-sm disabled:opacity-50">
|
||
{stopMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Square className="w-3 h-3 inline" /> Stop</>}
|
||
</button>
|
||
)}
|
||
{isRunning && (
|
||
<button onClick={() => restartMutation.mutate()} disabled={restartMutation.isPending} className="btn-secondary text-sm disabled:opacity-50">
|
||
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
|
||
</button>
|
||
)}
|
||
{!isInProgress && (
|
||
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
||
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
||
</button>
|
||
)}
|
||
{isRunning && (
|
||
<button onClick={() => previewMutation.mutate()} disabled={previewMutation.isPending} className="text-sm px-4 py-2 rounded-xl font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-all active:scale-[0.98]">
|
||
{previewMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Globe className="w-3 h-3 inline" /> Preview</>}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : '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.clusterId && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-sm text-gray-500">Cluster</dt>
|
||
<dd className="text-sm font-medium text-gray-900">
|
||
<Server className="w-4 h-4 inline text-gray-400" /> {clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
{app.poolId && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-sm text-gray-500">Pool</dt>
|
||
<dd className="text-sm font-medium text-gray-900">
|
||
<Scale className="w-4 h-4 inline text-gray-400" /> {pools.find((p) => p.id === app.poolId)?.name || 'Unknown'}
|
||
</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">
|
||
<Package className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||
<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/80 rounded-xl">
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium text-gray-900 truncate">{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" title={d.errorMessage}>
|
||
<XCircle className="w-3 h-3 inline" /> {d.errorMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<span className={`badge ${statusColors[d.status] || 'badge-gray'} ml-2 shrink-0`}>
|
||
{d.status}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Source Code Upload */}
|
||
<div className="card">
|
||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> 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">
|
||
<CheckCircle className="w-5 h-5" />
|
||
</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">
|
||
<Link className="w-5 h-5" />
|
||
</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 className="flex items-center space-x-3 mt-1">
|
||
{app.gitBranch && (
|
||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||
<GitBranch className="w-3 h-3" /> {app.gitBranch}
|
||
</span>
|
||
)}
|
||
{app.gitToken && (
|
||
<span className="text-xs text-green-600 flex items-center gap-1">
|
||
<KeyRound className="w-3 h-3" /> Private
|
||
</span>
|
||
)}
|
||
</div>
|
||
</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">
|
||
<Upload className="w-8 h-8 mx-auto text-gray-400 animate-pulse" />
|
||
<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">
|
||
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
|
||
<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>
|
||
|
||
{/* Resource Monitoring & Scaling */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Resources & Scaling</h2>
|
||
<button
|
||
onClick={() => setShowResources(!showResources)}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
{showResources ? <><ChevronDown className="w-4 h-4 inline" /> Hide</> : <><BarChart3 className="w-4 h-4 inline" /> Monitor</>}
|
||
</button>
|
||
</div>
|
||
|
||
{showResources && (
|
||
<div className="space-y-6">
|
||
{/* Live Metrics */}
|
||
{resourcesLoading ? (
|
||
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics...</div>
|
||
) : resourceUsage ? (
|
||
<>
|
||
{/* Cluster Status */}
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="bg-blue-50 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-blue-500 font-medium">Replicas</p>
|
||
<p className="text-2xl font-bold text-blue-700">
|
||
{resourceUsage.configured.readyReplicas}/{resourceUsage.configured.replicas}
|
||
</p>
|
||
<p className="text-xs text-blue-400">ready</p>
|
||
</div>
|
||
<div className="bg-green-50 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-green-500 font-medium">Pods</p>
|
||
<p className="text-2xl font-bold text-green-700">{resourceUsage.pods.length}</p>
|
||
<p className="text-xs text-green-400">
|
||
{resourceUsage.pods.filter((p) => p.ready).length} ready
|
||
</p>
|
||
</div>
|
||
<div className="bg-purple-50 rounded-xl p-4 text-center">
|
||
<p className="text-xs text-purple-500 font-medium">Metrics</p>
|
||
<p className="text-2xl font-bold text-purple-700">
|
||
{resourceUsage.metrics.length > 0 ? <CheckCircle className="w-6 h-6 mx-auto text-purple-700" /> : <Clock className="w-6 h-6 mx-auto text-purple-400" />}
|
||
</p>
|
||
<p className="text-xs text-purple-400">
|
||
{resourceUsage.metrics.length > 0 ? 'Available' : 'Waiting...'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Per-Pod Metrics */}
|
||
{resourceUsage.metrics.length > 0 && (
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-semibold text-gray-700">Pod Usage</h3>
|
||
{resourceUsage.metrics.map((metric) => {
|
||
const cpuUsed = parseCpuToMillicores(metric.cpu);
|
||
const cpuLimit = parseCpuToMillicores(resourceUsage.configured.cpuLimit);
|
||
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
|
||
|
||
const memUsed = parseMemoryToMi(metric.memory);
|
||
const memLimit = parseMemoryToMi(resourceUsage.configured.memoryLimit);
|
||
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
|
||
|
||
return (
|
||
<div key={metric.name} className="bg-gray-50 rounded-xl p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-mono text-gray-600 truncate max-w-[250px]" title={metric.name}>
|
||
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
|
||
</p>
|
||
</div>
|
||
|
||
{/* CPU Bar */}
|
||
<div>
|
||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>CPU</span>
|
||
<span>
|
||
{cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||
<div
|
||
className={`h-2.5 rounded-full transition-all duration-500 ${
|
||
cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
||
}`}
|
||
style={{ width: `${cpuPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Memory Bar */}
|
||
<div>
|
||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||
<span>Memory</span>
|
||
<span>
|
||
{memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)
|
||
</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||
<div
|
||
className={`h-2.5 rounded-full transition-all duration-500 ${
|
||
memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
||
}`}
|
||
style={{ width: `${memPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pod Status Table */}
|
||
{resourceUsage.pods.length > 0 && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-700 mb-2">Pod Status</h3>
|
||
<div className="overflow-x-auto -mx-2 px-2">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||
<th className="pb-2 font-medium">Pod</th>
|
||
<th className="pb-2 font-medium">Status</th>
|
||
<th className="pb-2 font-medium">Ready</th>
|
||
<th className="pb-2 font-medium">Restarts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{resourceUsage.pods.map((pod) => (
|
||
<tr key={pod.name} className="text-gray-700">
|
||
<td className="py-2 font-mono truncate max-w-[200px]" title={pod.name}>
|
||
{pod.name}
|
||
</td>
|
||
<td className="py-2">
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||
pod.status === 'Running' ? 'bg-green-100 text-green-700' :
|
||
pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' :
|
||
'bg-red-100 text-red-700'
|
||
}`}>
|
||
{pod.status}
|
||
</span>
|
||
</td>
|
||
<td className="py-2">{pod.ready ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Clock className="w-4 h-4 text-yellow-500" />}</td>
|
||
<td className="py-2">{pod.restarts}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scaling Controls */}
|
||
<div className="border-t pt-4">
|
||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Scale Resources</h3>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
|
||
<input
|
||
type="text"
|
||
value={resourceForm.cpuRequest}
|
||
onChange={(e) => setResourceForm((f) => ({ ...f, cpuRequest: e.target.value }))}
|
||
className="input-field text-sm"
|
||
placeholder="100m"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">CPU Limit</label>
|
||
<input
|
||
type="text"
|
||
value={resourceForm.cpuLimit}
|
||
onChange={(e) => setResourceForm((f) => ({ ...f, cpuLimit: e.target.value }))}
|
||
className="input-field text-sm"
|
||
placeholder="500m"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Memory Request</label>
|
||
<input
|
||
type="text"
|
||
value={resourceForm.memoryRequest}
|
||
onChange={(e) => setResourceForm((f) => ({ ...f, memoryRequest: e.target.value }))}
|
||
className="input-field text-sm"
|
||
placeholder="128Mi"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Memory Limit</label>
|
||
<input
|
||
type="text"
|
||
value={resourceForm.memoryLimit}
|
||
onChange={(e) => setResourceForm((f) => ({ ...f, memoryLimit: e.target.value }))}
|
||
className="input-field text-sm"
|
||
placeholder="512Mi"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
||
className="btn-icon w-9 h-9"
|
||
>
|
||
−
|
||
</button>
|
||
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
|
||
<button
|
||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
||
className="btn-icon w-9 h-9"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<button
|
||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||
disabled={scaleMutation.isPending}
|
||
className="btn-primary text-sm w-full disabled:opacity-50"
|
||
>
|
||
{scaleMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Applying...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="text-center py-6">
|
||
<p className="text-gray-400 text-sm">
|
||
{isStopped ? 'Application is stopped. Start it to see resources.' : 'No resource data available yet.'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Logs — Pod & Build */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><FileText className="w-5 h-5" /> Logs</h2>
|
||
<div className="flex items-center space-x-3">
|
||
{showLogs && logTab === 'pod' && (
|
||
<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>
|
||
)}
|
||
{showLogs && logTab === 'build' && (
|
||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||
<span>Auto-refresh (every 5s)</span>
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => setShowLogs(!showLogs)}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
{showLogs ? <><ChevronDown className="w-4 h-4 inline" /> Hide Logs</> : <><FileText className="w-4 h-4 inline" /> Show Logs</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{showLogs && (
|
||
<div className="space-y-3">
|
||
{/* Tab switcher */}
|
||
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
|
||
<button
|
||
onClick={() => setLogTab('pod')}
|
||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||
logTab === 'pod'
|
||
? 'bg-white text-gray-900 shadow-sm'
|
||
: 'text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Monitor className="w-4 h-4 inline" /> Pod Logs
|
||
</button>
|
||
<button
|
||
onClick={() => setLogTab('build')}
|
||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||
logTab === 'build'
|
||
? 'bg-white text-gray-900 shadow-sm'
|
||
: 'text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
<Hammer className="w-4 h-4 inline" /> Build Logs
|
||
</button>
|
||
</div>
|
||
|
||
{/* Pod logs */}
|
||
{logTab === 'pod' && (
|
||
<pre
|
||
ref={logsEndRef}
|
||
className="bg-gray-900 text-green-400 p-4 rounded-xl 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>
|
||
)}
|
||
|
||
{/* Build logs */}
|
||
{logTab === 'build' && (
|
||
<div>
|
||
{buildLogsData?.version && (
|
||
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
|
||
<span><Pin className="w-3 h-3 inline" /> {buildLogsData.version}</span>
|
||
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
|
||
{buildLogsData.status}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||
{buildLogsData?.buildLog || (
|
||
buildLogsData?.status === 'building'
|
||
? 'Build in progress... Logs will appear when complete.'
|
||
: buildLogsData?.status === 'pending'
|
||
? 'Build is pending...'
|
||
: buildLogsData?.status === 'no_deployment'
|
||
? 'No deployments yet. Deploy your app to see build logs.'
|
||
: 'No build logs available for this deployment.'
|
||
)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|