Files
cloud-host/frontend/src/app/dashboard/apps/[id]/page.tsx
T
keyhan 1b1ccfc18f feat: support private git repositories with token auth
- 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
2026-04-05 16:48:35 +03:30

787 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-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>
);
}