feat: per-workload resources, storage GiB metrics, optional service disks
- Expose DB/Redis/RabbitMQ usage plus sidecars in getResourceUsage - Storage API: GiB fields, Redis/Rabbit PVC usage, fix du/exec container names - PATCH /resources accepts workload; persist entity fields only for app - App detail: workload cards, disk bars, DB expand, scale target select Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -69,6 +69,8 @@ export default function AppDetailPage() {
|
||||
memoryLimit: '',
|
||||
replicas: 1,
|
||||
});
|
||||
const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app');
|
||||
const [showDbDiskExpand, setShowDbDiskExpand] = useState(false);
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||
@@ -93,6 +95,13 @@ export default function AppDetailPage() {
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) return;
|
||||
if (scaleWorkload === 'database' && app.databaseType === 'none') setScaleWorkload('app');
|
||||
else if (scaleWorkload === 'redis' && !app.enableRedis) setScaleWorkload('app');
|
||||
else if (scaleWorkload === 'rabbitmq' && !app.enableRabbitmq) setScaleWorkload('app');
|
||||
}, [app, scaleWorkload]);
|
||||
|
||||
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
|
||||
@@ -146,9 +155,21 @@ export default function AppDetailPage() {
|
||||
}, [dbStorageData]);
|
||||
|
||||
// Fetch comprehensive storage usage (allocated/used/available)
|
||||
interface StorageUsageSlice {
|
||||
allocatedRaw: string;
|
||||
allocatedGi: number;
|
||||
usedGi: number;
|
||||
availableGi: number;
|
||||
usedPercent: number;
|
||||
}
|
||||
interface StorageUsageData {
|
||||
database: { allocated: number; used: number; available: number } | null;
|
||||
appStorage: { allocated: number; used: number; available: number } | null;
|
||||
database: StorageUsageSlice | null;
|
||||
appStorage: StorageUsageSlice | null;
|
||||
redisStorage?: StorageUsageSlice | null;
|
||||
rabbitmqStorage?: StorageUsageSlice | null;
|
||||
totalAllocatedGb?: number;
|
||||
totalUsedGb?: number;
|
||||
configured?: { dbStorageSize: string; appStorageSize: string };
|
||||
}
|
||||
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<StorageUsageData>({
|
||||
queryKey: ['storage-usage', appId],
|
||||
@@ -191,6 +212,7 @@ export default function AppDetailPage() {
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message || 'Database storage expanded!');
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
|
||||
} else {
|
||||
toast.error(res.data.message || 'Failed to expand storage');
|
||||
}
|
||||
@@ -445,18 +467,27 @@ export default function AppDetailPage() {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// Sync form when resource data loads
|
||||
// Sync form when resource data loads (selected workload)
|
||||
useEffect(() => {
|
||||
if (resourceUsage?.configured) {
|
||||
const workloads = resourceUsage?.workloads;
|
||||
const w =
|
||||
workloads?.find((x) => x.key === scaleWorkload) ||
|
||||
(scaleWorkload === 'app' && resourceUsage?.configured
|
||||
? {
|
||||
key: 'app' as const,
|
||||
configured: resourceUsage.configured,
|
||||
}
|
||||
: undefined);
|
||||
if (w?.configured) {
|
||||
setResourceForm({
|
||||
cpuRequest: resourceUsage.configured.cpuRequest,
|
||||
cpuLimit: resourceUsage.configured.cpuLimit,
|
||||
memoryRequest: resourceUsage.configured.memoryRequest,
|
||||
memoryLimit: resourceUsage.configured.memoryLimit,
|
||||
replicas: resourceUsage.configured.replicas,
|
||||
cpuRequest: w.configured.cpuRequest || '',
|
||||
cpuLimit: w.configured.cpuLimit || '',
|
||||
memoryRequest: w.configured.memoryRequest || '',
|
||||
memoryLimit: w.configured.memoryLimit || '',
|
||||
replicas: w.configured.replicas ?? 1,
|
||||
});
|
||||
}
|
||||
}, [resourceUsage?.configured]);
|
||||
}, [resourceUsage, scaleWorkload]);
|
||||
|
||||
// Auto-scroll logs to bottom
|
||||
useEffect(() => {
|
||||
@@ -544,6 +575,23 @@ export default function AppDetailPage() {
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
|
||||
});
|
||||
|
||||
/** CPU/memory for DB / Redis / RabbitMQ — applies directly in Kubernetes (no billing wizard). */
|
||||
const directPatchResourcesMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
workload: 'database' | 'redis' | 'rabbitmq';
|
||||
cpuRequest?: string;
|
||||
cpuLimit?: string;
|
||||
memoryRequest?: string;
|
||||
memoryLimit?: string;
|
||||
}) => api.patch(`/applications/${appId}/resources`, data),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
toast.success('Resources updated');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
|
||||
});
|
||||
|
||||
// Calculate upgrade cost before applying
|
||||
const calculateUpgradeCostMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
@@ -555,14 +603,22 @@ export default function AppDetailPage() {
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
|
||||
});
|
||||
|
||||
// Handler to check upgrade cost before applying
|
||||
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
|
||||
const handleScaleResources = () => {
|
||||
// If app doesn't have billing cycle (free/unmanaged), apply directly
|
||||
if (scaleWorkload !== 'app') {
|
||||
directPatchResourcesMutation.mutate({
|
||||
workload: scaleWorkload,
|
||||
cpuRequest: resourceForm.cpuRequest || undefined,
|
||||
cpuLimit: resourceForm.cpuLimit || undefined,
|
||||
memoryRequest: resourceForm.memoryRequest || undefined,
|
||||
memoryLimit: resourceForm.memoryLimit || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!app?.billingCycle) {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
return;
|
||||
}
|
||||
// Otherwise, calculate cost first
|
||||
calculateUpgradeCostMutation.mutate(resourceForm);
|
||||
};
|
||||
|
||||
@@ -1637,133 +1693,146 @@ export default function AppDetailPage() {
|
||||
<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>
|
||||
{resourceUsage.loggingNote && (
|
||||
<p className="text-xs text-gray-600 bg-slate-50 border border-slate-100 rounded-lg px-3 py-2">{resourceUsage.loggingNote}</p>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{(resourceUsage.workloads && resourceUsage.workloads.length > 0
|
||||
? resourceUsage.workloads
|
||||
: resourceUsage.configured
|
||||
? [
|
||||
{
|
||||
key: 'app' as const,
|
||||
title: 'Application',
|
||||
deploymentName: app?.name || '',
|
||||
configured: resourceUsage.configured,
|
||||
pods: resourceUsage.pods,
|
||||
metrics: resourceUsage.metrics,
|
||||
sidecars: undefined,
|
||||
},
|
||||
]
|
||||
: []
|
||||
).map((w) => (
|
||||
<div key={w.key} className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{w.title}</h3>
|
||||
<span className="text-[11px] text-gray-400 font-mono truncate max-w-[200px]" title={w.deploymentName}>{w.deploymentName}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
|
||||
<p className="text-lg font-bold text-blue-800">
|
||||
{w.configured.readyReplicas}/{w.configured.replicas}
|
||||
</p>
|
||||
<p className="text-[10px] text-blue-500">ready</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-green-600 font-medium">Pods</p>
|
||||
<p className="text-lg font-bold text-green-800">{w.pods.length}</p>
|
||||
<p className="text-[10px] text-green-500">{w.pods.filter((p) => p.ready).length} ready</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
|
||||
<p className="text-lg font-bold text-purple-800 flex justify-center">
|
||||
{w.metrics.length > 0 ? <CheckCircle className="w-5 h-5 text-purple-700" /> : <Clock className="w-5 h-5 text-purple-400" />}
|
||||
</p>
|
||||
<p className="text-[10px] text-purple-500">{w.metrics.length > 0 ? 'Live' : 'Waiting…'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 grid sm:grid-cols-2 gap-2 border-t border-gray-200 pt-3">
|
||||
<div>
|
||||
<span className="text-gray-400">CPU: </span>
|
||||
<span className="font-mono">{w.configured.cpuRequest} → {w.configured.cpuLimit}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Memory: </span>
|
||||
<span className="font-mono">{w.configured.memoryRequest} → {w.configured.memoryLimit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{w.metrics.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-600">Live usage</h4>
|
||||
{w.metrics.map((metric) => {
|
||||
const cpuUsed = parseCpuToMillicores(metric.cpu);
|
||||
const cpuLimit = parseCpuToMillicores(w.configured.cpuLimit);
|
||||
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
|
||||
const memUsed = parseMemoryToMi(metric.memory);
|
||||
const memLimit = parseMemoryToMi(w.configured.memoryLimit);
|
||||
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
|
||||
return (
|
||||
<div key={metric.name} className="bg-white rounded-lg p-3 space-y-2 border border-gray-100">
|
||||
<p className="text-[11px] font-mono text-gray-600 truncate" title={metric.name}>
|
||||
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
|
||||
</p>
|
||||
<div>
|
||||
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
|
||||
<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">
|
||||
<div className={`h-2 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${cpuPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
|
||||
<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">
|
||||
<div className={`h-2 rounded-full ${memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${memPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.pods.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-600 mb-1">Pods</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="pb-1 font-medium">Name</th>
|
||||
<th className="pb-1 font-medium">Status</th>
|
||||
<th className="pb-1 font-medium">Ready</th>
|
||||
<th className="pb-1 font-medium">R</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{w.pods.map((pod) => (
|
||||
<tr key={pod.name} className="text-gray-700">
|
||||
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
|
||||
<td className="py-1">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] 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-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
|
||||
<td className="py-1">{pod.restarts}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.sidecars && w.sidecars.length > 0 && (
|
||||
<div className="text-[11px] text-gray-600 border-t border-dashed border-gray-200 pt-2">
|
||||
<span className="font-semibold text-gray-700">Sidecars: </span>
|
||||
{w.sidecars.map((s) => (
|
||||
<span key={s.name} className="mr-3">
|
||||
{s.name} <span className="text-gray-400">(CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* Storage Usage Section */}
|
||||
<div className="border-t pt-4">
|
||||
@@ -1778,56 +1847,119 @@ export default function AppDetailPage() {
|
||||
{storageUsage.database && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">Database Storage</span>
|
||||
<span className="text-xs font-medium text-gray-600">Database volume</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.database.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
{storageUsage.database.usedGi.toFixed(2)} GiB / {storageUsage.database.allocatedGi.toFixed(1)} GiB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
(storageUsage.database.used / storageUsage.database.allocated) * 100 > 80
|
||||
storageUsage.database.usedPercent > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.database.used / storageUsage.database.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
: storageUsage.database.usedPercent > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.database.used / storageUsage.database.allocated) * 100, 100)}%` }}
|
||||
style={{ width: `${Math.min(storageUsage.database.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.database.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
|
||||
</div>
|
||||
{app?.databaseType !== 'none' && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
{showDbDiskExpand ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={dbStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1));
|
||||
setDbStorageSize(String(val));
|
||||
}}
|
||||
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
if (current < 500) setDbStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">GiB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
|
||||
disabled={
|
||||
resizeDbMutation.isPending ||
|
||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
||||
}
|
||||
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
{resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDbDiskExpand(true)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<Scale className="w-3 h-3" /> Expand database disk
|
||||
</button>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 mt-1">PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App Storage (WordPress wp-content) */}
|
||||
{storageUsage.appStorage && (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
{app?.runtime === 'wordpress' ? 'wp-content Storage' : 'App Storage'}
|
||||
{app?.runtime === 'wordpress' ? 'wp-content volume' : 'Application volume'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.appStorage.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
{storageUsage.appStorage.usedGi.toFixed(2)} GiB / {storageUsage.appStorage.allocatedGi.toFixed(1)} GiB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
(storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 80
|
||||
storageUsage.appStorage.usedPercent > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
: storageUsage.appStorage.usedPercent > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100, 100)}%` }}
|
||||
style={{ width: `${Math.min(storageUsage.appStorage.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.appStorage.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
|
||||
</div>
|
||||
|
||||
{/* Expand App Storage (all app types) */}
|
||||
@@ -1905,7 +2037,43 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageUsage.database && !storageUsage.appStorage && (
|
||||
{storageUsage.redisStorage && app?.enableRedis && (
|
||||
<div className="bg-amber-50/80 rounded-xl p-4 border border-amber-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-700">Redis (optional) volume</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${storageUsage.redisStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-amber-500'}`}
|
||||
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.redisStorage.allocatedRaw}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
|
||||
<div className="bg-violet-50/80 rounded-xl p-4 border border-violet-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-700">RabbitMQ (optional) volume</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${storageUsage.rabbitmqStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-violet-500'}`}
|
||||
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.rabbitmqStorage.allocatedRaw}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No storage data available</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1916,7 +2084,23 @@ export default function AppDetailPage() {
|
||||
|
||||
{/* 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>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"><Settings className="w-4 h-4" /> Adjust CPU / memory</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-500 mb-1">Workload</label>
|
||||
<select
|
||||
value={scaleWorkload}
|
||||
onChange={(e) => setScaleWorkload(e.target.value as typeof scaleWorkload)}
|
||||
className="input-field text-sm max-w-md"
|
||||
>
|
||||
<option value="app">Application</option>
|
||||
{app?.databaseType !== 'none' && <option value="database">Database</option>}
|
||||
{app?.enableRedis && <option value="redis">Redis</option>}
|
||||
{app?.enableRabbitmq && <option value="rabbitmq">RabbitMQ</option>}
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
@@ -1958,31 +2142,46 @@ export default function AppDetailPage() {
|
||||
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>
|
||||
{scaleWorkload === 'app' && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Replicas</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="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
|
||||
type="button"
|
||||
onClick={() => setResourceForm((f) => ({ ...f, replicas: Math.min(10, f.replicas + 1) }))}
|
||||
className="btn-icon w-9 h-9"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScaleResources}
|
||||
disabled={scaleMutation.isPending || calculateUpgradeCostMutation.isPending}
|
||||
disabled={
|
||||
scaleMutation.isPending ||
|
||||
calculateUpgradeCostMutation.isPending ||
|
||||
directPatchResourcesMutation.isPending
|
||||
}
|
||||
className="btn-primary text-sm w-full disabled:opacity-50"
|
||||
>
|
||||
{scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Calculating...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
||||
{directPatchResourcesMutation.isPending ? (
|
||||
<><Clock className="w-3 h-3 inline animate-spin" /> Applying…</>
|
||||
) : scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? (
|
||||
<><Clock className="w-3 h-3 inline animate-spin" /> Calculating…</>
|
||||
) : (
|
||||
<><RefreshCw className="w-3 h-3 inline" /> Apply changes</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user