1b1ccfc18f
- Entity: added gitToken and gitBranch columns to Application
- DTO: added gitToken and gitBranch fields to Create/Update DTOs
- Build pipeline: git-clone init container now injects token into
HTTPS URL for private repo authentication (GitHub PAT, GitLab token, etc.)
- Build pipeline: supports cloning specific branch (default: main)
- Frontend deploy page: added Access Token (password field) and Branch
inputs when Git Repository source is selected
- Frontend deploy page: Review step shows branch and token status
- Frontend app detail: shows branch badge and 🔑 Private indicator
when git credentials are configured
- Works with: GitHub, GitLab, Bitbucket, any HTTPS-based git host
787 lines
34 KiB
TypeScript
787 lines
34 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-hot-toast';
|
||
import type { Application, Deployment, ResourceUsage } 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',
|
||
};
|
||
|
||
/**
|
||
* 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 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,
|
||
refetchInterval: showLogs ? 3000 : 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,
|
||
});
|
||
|
||
// 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="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>
|
||
)}
|
||
|
||
{/* Redeploy: rebuild from latest git/code */}
|
||
{!isInProgress && (
|
||
<button
|
||
onClick={() => redeployMutation.mutate()}
|
||
disabled={redeployMutation.isPending}
|
||
className="btn-primary text-sm disabled:opacity-50"
|
||
title="Rebuild from latest source code and deploy new version"
|
||
>
|
||
{redeployMutation.isPending ? '⏳ Rebuilding...' : '🔄 Redeploy'}
|
||
</button>
|
||
)}
|
||
|
||
{/* Preview: open the running app in a new tab */}
|
||
{isRunning && (
|
||
<button
|
||
onClick={() => previewMutation.mutate()}
|
||
disabled={previewMutation.isPending}
|
||
className="px-4 py-2 rounded-lg text-sm font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-colors"
|
||
title="Open the running application in a new browser tab"
|
||
>
|
||
{previewMutation.isPending ? '⏳ Loading...' : '🌐 Preview'}
|
||
</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 className="flex items-center space-x-3 mt-1">
|
||
{app.gitBranch && (
|
||
<span className="text-xs text-blue-500">
|
||
🌿 {app.gitBranch}
|
||
</span>
|
||
)}
|
||
{app.gitToken && (
|
||
<span className="text-xs text-green-600">
|
||
🔑 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">
|
||
<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>
|
||
|
||
{/* Resource Monitoring & Scaling */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-lg font-semibold text-gray-900">📊 Resources & Scaling</h2>
|
||
<button
|
||
onClick={() => setShowResources(!showResources)}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
{showResources ? '🔽 Hide' : '📊 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 ? '✅' : '⏳'}
|
||
</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}>
|
||
🟢 {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">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-left text-gray-500 border-b">
|
||
<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 ? '✅' : '⏳'}</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">⚙️ Scale Resources</h3>
|
||
<div className="grid grid-cols-2 md: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="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
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="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
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="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
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="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||
placeholder="512Mi"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||
<div className="flex items-center space-x-2">
|
||
<button
|
||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.max(1, f.replicas - 1) }))}
|
||
className="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||
>
|
||
−
|
||
</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="w-8 h-8 flex items-center justify-center bg-gray-100 rounded-lg hover:bg-gray-200 text-sm font-bold"
|
||
>
|
||
+
|
||
</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 ? '⏳ Applying...' : '🔄 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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|