Files
cloud-host/frontend/src/app/dashboard/apps/[id]/page.tsx
T
keyhan 3c3e0e48fa refactor: migrate from react-hot-toast to react-toastify and replace all emoji icons with Lucide React
- 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
2026-04-06 17:20:56 +03:30

863 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}