'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import api from '@/lib/api'; import { queryKeys } from '@/lib/query-keys'; import { notify } from '@/lib/notify'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; import { Link as NextLink } from '@/i18n/Link'; import { useT, useLocale } from '@/i18n/I18nProvider'; import { useLocalizedRouter } from '@/i18n/navigation'; 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, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ScrollText } from 'lucide-react'; import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel'; import { WorkloadLogsPanel } from '@/components/workload-logs-panel'; import { DeletingModal } from '@/components/deleting-modal'; import { useApplicationDelete } from '@/lib/use-application-delete'; import { isApplicationProduct, isManagedProduct } from '@/lib/product-type'; import { useConfirm } from '@/components/confirm-modal'; import { useAuthStore } from '@/lib/store'; import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions'; import { useDeployProgressStore } from '@/lib/deploy-progress-store'; import { Select } from '@/components/ui/select'; /** Matches backend multipart limit for POST /applications/:id/upload */ const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3; type UpgradePayload = { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number; dbStorageSize?: string; appStorageSize?: string; redisResources?: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; storageGi?: number; }; rabbitmqResources?: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; storageGi?: number; }; }; const statusColors: Record = { running: 'badge-green', pending: 'badge-yellow', building: 'badge-blue', deploying: 'badge-blue', failed: 'badge-red', build_failed: 'badge-red', cancelled: 'badge-gray', 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 t = useT(); const ad = t.dashboard.appDetail; const locale = useLocale(); const statusLabel = (st: string) => (t.components.deployStatus as Record)[st] ?? st; const params = useParams(); const router = useLocalizedRouter(); const queryClient = useQueryClient(); const { notifyDeployStarted } = useDeployProgressActions(); const confirm = useConfirm(); const appId = params.id as string; const user = useAuthStore((s) => s.user); const isAdmin = user?.role === 'admin'; const fileInputRef = useRef(null); const [uploadProgress, setUploadProgress] = useState(0); const [isDragging, setIsDragging] = useState(false); const [showResources, setShowResources] = useState(false); const [showDbPassword, setShowDbPassword] = useState(false); const [copiedField, setCopiedField] = useState(null); const [dbRestoreLogs, setDbRestoreLogs] = useState(null); const dbFileInputRef = useRef(null); const [isDraggingDb, setIsDraggingDb] = useState(false); const [resourceForm, setResourceForm] = useState({ cpuRequest: '', cpuLimit: '', memoryRequest: '', memoryLimit: '', replicas: 1, }); const [scaleWorkload, setScaleWorkload] = useState<'app' | 'database' | 'redis' | 'rabbitmq'>('app'); const [resourceFormDirty, setResourceFormDirty] = useState(false); const [dbStorageSize, setDbStorageSize] = useState('1'); const [redisStorageSize, setRedisStorageSize] = useState('1'); const [rabbitmqStorageSize, setRabbitmqStorageSize] = useState('2'); const [dbStorageLoading, setDbStorageLoading] = useState(false); const [showSnapshots, setShowSnapshots] = useState(false); const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null); const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions'); const [showRenewalModal, setShowRenewalModal] = useState(false); const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly'); const [renewCoupon, setRenewCoupon] = useState(''); const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false); const [upgradeCostData, setUpgradeCostData] = useState<{ proratedAmount: number; remainingHours: number; currentCost: { hourly: number }; newCost: { hourly: number }; } | null>(null); const [pendingUpgradePayload, setPendingUpgradePayload] = useState(null); // ── Custom Domain ────────────────────────────────── const [showDomainSetup, setShowDomainSetup] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); const [showServiceSecrets, setShowServiceSecrets] = useState(false); const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), }); useEffect(() => { if (app && isManagedProduct(app)) { router.replace(`/dashboard/services/${appId}`); } }, [app, appId, router]); 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({ queryKey: ['deployments', appId], queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data), refetchInterval: 5000, // Poll for status updates }); const { data: serviceCredentials } = useQuery({ queryKey: ['service-credentials', appId], queryFn: () => api.get(`/applications/${appId}/service-credentials`).then((r) => r.data), enabled: !!app?.latestImageTag && (!!app?.enableRedis || !!app?.enableRabbitmq), }); const { data: resourceUsage, isLoading: resourcesLoading } = useQuery({ queryKey: ['resources', appId], queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data), enabled: showResources, refetchInterval: showResources ? 5000 : false, }); const { data: clusters = [] } = useQuery({ queryKey: ['clusters-public'], queryFn: () => api.get('/clusters/public').then((r) => r.data), enabled: isAdmin, }); const { data: pools = [] } = useQuery({ queryKey: ['pools-public'], queryFn: () => api.get('/clusters/pools/public').then((r) => r.data), enabled: isAdmin, }); // Fetch DB storage size const { data: dbStorageData } = useQuery<{ currentSize: string; savedSize: string }>({ queryKey: ['db-storage', appId], queryFn: () => api.get(`/applications/${appId}/db-storage`).then((r) => r.data), enabled: !!app && app.databaseType !== 'none', }); // Sync dbStorageSize state when data loads useEffect(() => { if (dbStorageData?.currentSize) { const sizeNum = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1; setDbStorageSize(String(sizeNum)); } }, [dbStorageData]); // Fetch comprehensive storage usage (allocated/used/available) interface StorageUsageSlice { allocatedRaw: string; allocatedGi: number; usedGi: number; availableGi: number; usedPercent: number; } interface StorageUsageData { 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({ queryKey: ['storage-usage', appId], queryFn: () => api.get(`/applications/${appId}/storage`).then((r) => r.data), enabled: showResources && !!app, refetchInterval: showResources ? 15000 : false, }); // App storage expansion state const [appStorageSize, setAppStorageSize] = useState('2'); useEffect(() => { if (app?.appStorageSize) { const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2; setAppStorageSize(String(sizeNum)); } }, [app?.appStorageSize]); useEffect(() => { if (storageUsage?.redisStorage) { setRedisStorageSize(String(Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)))); } }, [storageUsage?.redisStorage?.allocatedGi]); useEffect(() => { if (storageUsage?.rabbitmqStorage) { setRabbitmqStorageSize(String(Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)))); } }, [storageUsage?.rabbitmqStorage?.allocatedGi]); // ─── Billing & Renewal ────────────────────────────── const { data: walletData } = useQuery<{ balance: number }>({ queryKey: queryKeys.walletBalance, queryFn: () => api.get('/billing/wallet').then((r) => r.data), }); const { data: renewalCostData } = useQuery<{ costs: { hourly: number; monthly: number; yearly: number; currentCycle?: string }; }>({ queryKey: ['renewal-cost', appId], queryFn: () => api.get(`/billing/applications/${appId}/renewal-cost`).then((r) => r.data), enabled: showRenewalModal || (app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'), }); const renewMutation = useMutation({ mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle, couponCode: renewCoupon.trim() || undefined, }), onSuccess: (res) => { notify.success(res.data.message || 'Application renewed successfully!'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); setShowRenewalModal(false); setRenewCoupon(''); }, onError: (err: any) => { notify.error(err, 'Failed to renew application'); }, }); const createRenewalInvoiceMutation = useMutation({ mutationFn: (cycle: string) => api .post(`/billing/applications/${appId}/renew/invoice`, { cycle, couponCode: renewCoupon.trim() || undefined, }) .then((r) => r.data), onSuccess: (invoice) => { notify.success(ad.invoiceCreated); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowRenewalModal(false); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => { notify.error(err, 'Failed to create renewal invoice'); }, }); // Check if app needs renewal (expired or suspended) const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'; const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000); // ─── Custom Domain ────────────────────────────────── const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{ customDomain: string | null; customDomainStatus: string; platformDomain: string; fullPlatformUrl: string; cnameTarget: string; instructions: string[]; }>({ queryKey: ['domain-info', appId], queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data), // Always load: we surface the active app domain (platform host included) even // when no custom domain is configured, so the platform host must be available. enabled: !!app, }); const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ queryKey: ['custom-domain-price'], queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data), }); const setDomainMutation = useMutation({ mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }), onSuccess: () => { notify.success(ad.domainSet); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); setCustomDomainInput(''); }, onError: (err: any) => notify.error(err, 'Failed to set domain'), }); const verifyDnsMutation = useMutation({ mutationFn: () => api.post(`/applications/${appId}/domain/verify`), onSuccess: (res) => { if (res.data.verified) { notify.success(ad.domainVerified); } else { notify.warning(res.data.message || 'DNS is not ready yet. Please try again later.'); } queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, onError: (err: any) => notify.error(err, 'DNS verification failed'), }); const removeDomainMutation = useMutation({ mutationFn: () => api.delete(`/applications/${appId}/domain`), onSuccess: () => { notify.success(ad.customDomainRemoved); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, onError: (err: any) => notify.error(err, 'Failed to remove domain'), }); // ─── Snapshots ────────────────────────────────────── const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery({ queryKey: ['snapshots', appId], queryFn: () => api.get(`/snapshots/applications/${appId}`).then((r) => r.data), enabled: showSnapshots && snapshotTab === 'snapshots', refetchInterval: showSnapshots && snapshotTab === 'snapshots' ? 10000 : false, }); // ─── K8s Revisions (instant rollback) ────────────── const { data: revisionData, isLoading: revisionsLoading } = useQuery({ queryKey: ['revisions', appId], queryFn: () => api.get(`/snapshots/applications/${appId}/revisions`).then((r) => r.data), enabled: showSnapshots && snapshotTab === 'revisions', refetchInterval: showSnapshots && snapshotTab === 'revisions' ? 10000 : false, }); const revisionRollbackMutation = useMutation({ mutationFn: (revision: number) => api.post(`/snapshots/applications/${appId}/revisions/${revision}/rollback`), onSuccess: (res) => { notify.success(res.data.message || 'Rollback completed'); queryClient.invalidateQueries({ queryKey: ['revisions', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, onError: (err: any) => notify.error(err, ad.rollbackFailed), }); const handleRevisionRollback = async (rev: K8sRevision) => { const ok = await confirm({ title: ad.rollbackRevTitle.replace('{n}', String(rev.revision)), message: ad.rollbackRevMessage .replace('{n}', String(rev.revision)) .replace('{cause}', rev.changeCause ? `\n\n${ad.description}: ${rev.changeCause}` : ''), confirmText: ad.rollback, variant: 'warning', }); if (ok) revisionRollbackMutation.mutate(rev.revision); }; const createSnapshotMutation = useMutation({ mutationFn: () => api.post(`/snapshots/applications/${appId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); notify.success(ad.snapshotStarted); }, onError: () => notify.error(ad.snapshotCreateFailed), }); const rollbackMutation = useMutation({ mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`), onSuccess: (res) => { const details = res.data.details || []; notify.success(ad.rollbackCompleted + details.join('\n')); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, onError: () => notify.error(ad.rollbackFailed), }); const [deletingSnapshotId, setDeletingSnapshotId] = useState(null); const deleteSnapshotMutation = useMutation({ mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`), onMutate: (snapshotId) => { setDeletingSnapshotId(snapshotId); }, onSettled: () => { setDeletingSnapshotId(null); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); notify.success(ad.snapshotDeleted); }, onError: () => notify.error(ad.snapshotDeleteFailed), }); const handleRollback = async (snap: AppSnapshot) => { const ok = await confirm({ title: ad.rollbackSnapTitle.replace('{label}', snap.label || ''), message: ad.rollbackSnapMessage, confirmText: ad.rollback, variant: 'warning', }); if (ok) rollbackMutation.mutate(snap.id); }; const handleDeleteSnapshot = async (snap: AppSnapshot) => { const ok = await confirm({ title: ad.deleteSnapshotTitle, message: ad.deleteSnapshotMessage.replace('{label}', snap.label || ''), confirmText: t.common.delete, variant: 'danger', }); if (ok) deleteSnapshotMutation.mutate(snap.id); }; const downloadSnapshotArtifact = (snapshotId: string, artifact: 'source' | 'wp-content' | 'database') => { const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/${artifact}`; const token = localStorage.getItem('accessToken'); const a = document.createElement('a'); a.href = url; // Use fetch for auth download fetch(url, { headers: { Authorization: `Bearer ${token}` } }) .then((r) => r.blob()) .then((blob) => { const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql'; const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${artifact}-${snapshotId.slice(0, 8)}${ext}`; link.click(); URL.revokeObjectURL(link.href); }) .catch(() => notify.error(ad.downloadFailed.replace('{name}', artifact))); }; const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => { // Prevent duplicate downloads if (downloadingArtifact) { notify.warning(ad.downloadInProgress); return; } setDownloadingArtifact(artifact); const url = `${api.defaults.baseURL}/snapshots/applications/${appId}/current/${artifact}`; const token = localStorage.getItem('accessToken'); const timeout = 15 * 60 * 1000; // 15 minutes const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const artifactName = artifact === 'source' ? 'Source Code' : artifact === 'wp-content' ? 'wp-content' : 'Database'; notify.info(ad.downloading.replace('{name}', artifactName)); fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }) .then((r) => { clearTimeout(timeoutId); if (!r.ok) throw new Error('Not found'); return r.blob(); }) .then((blob) => { const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql'; const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `current-${artifact}${ext}`; link.click(); URL.revokeObjectURL(link.href); notify.success(ad.downloadedSuccess.replace('{name}', artifactName)); }) .catch((err) => { clearTimeout(timeoutId); if (err.name === 'AbortError') { notify.error(ad.downloadTimeout); } else { notify.error(ad.downloadCurrentFailed.replace('{name}', artifact)); } }) .finally(() => { setDownloadingArtifact(null); }); }; const formatBytes = (bytes?: number) => { if (!bytes) return '—'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const patchResourceForm = (patch: Partial) => { setResourceFormDirty(true); setResourceForm((f) => ({ ...f, ...patch })); }; // Sync form when workload changes or when live metrics refresh — but not while user is editing. useEffect(() => { setResourceFormDirty(false); }, [scaleWorkload]); useEffect(() => { if (resourceFormDirty) return; 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: w.configured.cpuRequest || '', cpuLimit: w.configured.cpuLimit || '', memoryRequest: w.configured.memoryRequest || '', memoryLimit: w.configured.memoryLimit || '', replicas: w.configured.replicas ?? 1, }); } }, [resourceUsage, scaleWorkload, resourceFormDirty]); // Auto-scroll logs to bottom const invalidateAll = () => { queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['applications'] }); }; const deployMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`), onMutate: () => { notifyDeployStarted(appId, app?.name); }, onSuccess: () => { invalidateAll(); notify.success(ad.deploymentTriggered); }, onError: () => { useDeployProgressStore.getState().stopTracking(appId); notify.error(ad.deployFailed); }, }); const stopMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/stop`), onSuccess: () => { invalidateAll(); notify.success(ad.appStopped); }, onError: () => notify.error(ad.stopFailed), }); const startMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/start`), onSuccess: () => { invalidateAll(); notify.success(ad.appStarted); }, onError: () => notify.error(ad.startFailed), }); const restartMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/restart`), onSuccess: () => { invalidateAll(); notify.success(ad.appRestarting); }, onError: () => notify.error(ad.restartFailed), }); const redeployMutation = useMutation({ mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`), onMutate: () => { notifyDeployStarted(appId, app?.name); }, onSuccess: () => { invalidateAll(); notify.success(ad.redeployStarted); }, onError: () => { useDeployProgressStore.getState().stopTracking(appId); notify.error(ad.redeployFailed); }, }); const { deleteApplication, isAnyDeleting } = useApplicationDelete({ invalidateKeys: [['applications', 'application'], ['applications']], onSuccess: (data) => { if (data?.resourceCredit) { notify.success(ad.appDeletedCredit); } else { notify.success(ad.appDeleted); } router.push('/dashboard/apps'); }, onError: () => notify.error(ad.deleteAppFailed), }); const scaleMutation = useMutation({ mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade`, data), onSuccess: (res) => { setResourceFormDirty(false); invalidateAll(); queryClient.invalidateQueries({ queryKey: ['resources', appId] }); queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); queryClient.invalidateQueries({ queryKey: ['db-storage', appId] }); queryClient.invalidateQueries({ queryKey: queryKeys.walletBalance }); setShowUpgradeConfirm(false); setUpgradeCostData(null); setPendingUpgradePayload(null); const paidAmount = res.data.paidAmount || 0; if (paidAmount > 0) { notify.success(ad.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US'))); } else { notify.success(ad.resourcesUpdatedSuccess); } }, onError: (err: any) => notify.error(err, ad.updateResourcesFailed), }); /** 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: () => { setResourceFormDirty(false); invalidateAll(); queryClient.invalidateQueries({ queryKey: ['resources', appId] }); notify.success(ad.resourcesUpdated); }, onError: (err: any) => notify.error(err, ad.updateResourcesFailed), }); // Calculate upgrade cost before applying const calculateUpgradeCostMutation = useMutation({ mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/calculate`, data), onSuccess: (res) => { setUpgradeCostData(res.data); setShowUpgradeConfirm(true); }, onError: (err: any) => notify.error(err, ad.calcFailed), }); const createUpgradeInvoiceMutation = useMutation({ mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data), onSuccess: (invoice) => { notify.success(ad.invoiceCreated); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); setPendingUpgradePayload(null); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => notify.error(err, ad.invoiceFailed), }); const buildWorkloadUpgradePayload = useCallback((): UpgradePayload => { if (!app) return {}; switch (scaleWorkload) { case 'app': { const payload: UpgradePayload = { cpuRequest: resourceForm.cpuRequest || undefined, cpuLimit: resourceForm.cpuLimit || undefined, memoryRequest: resourceForm.memoryRequest || undefined, memoryLimit: resourceForm.memoryLimit || undefined, replicas: resourceForm.replicas, }; const minAppGi = parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2; const newAppGi = parseInt(appStorageSize, 10); if (newAppGi > minAppGi) payload.appStorageSize = `${newAppGi}Gi`; return payload; } case 'database': { const minDbGi = parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1; const newDbGi = parseInt(dbStorageSize, 10); if (newDbGi > minDbGi) return { dbStorageSize: `${newDbGi}Gi` }; return {}; } case 'redis': { const prev = app.optionalServiceResources?.redis; const minGi = Math.max(1, Math.round(storageUsage?.redisStorage?.allocatedGi ?? prev?.storageGi ?? 1)); const newGi = parseInt(redisStorageSize, 10); return { redisResources: { cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest, cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit, memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest, memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit, storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi), }, }; } case 'rabbitmq': { const prev = app.optionalServiceResources?.rabbitmq; const minGi = Math.max(2, Math.round(storageUsage?.rabbitmqStorage?.allocatedGi ?? prev?.storageGi ?? 2)); const newGi = parseInt(rabbitmqStorageSize, 10); return { rabbitmqResources: { cpuRequest: resourceForm.cpuRequest || prev?.cpuRequest, cpuLimit: resourceForm.cpuLimit || prev?.cpuLimit, memoryRequest: resourceForm.memoryRequest || prev?.memoryRequest, memoryLimit: resourceForm.memoryLimit || prev?.memoryLimit, storageGi: newGi > minGi ? newGi : (prev?.storageGi ?? minGi), }, }; } default: return {}; } }, [ app, scaleWorkload, resourceForm, appStorageSize, dbStorageSize, redisStorageSize, rabbitmqStorageSize, dbStorageData?.currentSize, storageUsage?.redisStorage?.allocatedGi, storageUsage?.rabbitmqStorage?.allocatedGi, ]); const patchDatabaseCpuIfNeeded = () => { if (scaleWorkload !== 'database') return; directPatchResourcesMutation.mutate({ workload: 'database', cpuRequest: resourceForm.cpuRequest || undefined, cpuLimit: resourceForm.cpuLimit || undefined, memoryRequest: resourceForm.memoryRequest || undefined, memoryLimit: resourceForm.memoryLimit || undefined, }); }; const handleScaleResources = () => { if (!app) return; if (app.lifecycleStatus && app.lifecycleStatus !== 'active') { if (scaleWorkload === 'database') { patchDatabaseCpuIfNeeded(); return; } notify.warning(ad.renewFirst); return; } const payload = buildWorkloadUpgradePayload(); const hasBillingPayload = Object.keys(payload).length > 0; const needsDbCpuPatch = scaleWorkload === 'database'; if (!hasBillingPayload && !needsDbCpuPatch) { notify.warning(ad.noChanges); return; } if (!app.billingCycle) { if (hasBillingPayload) { scaleMutation.mutate(payload, { onSuccess: () => patchDatabaseCpuIfNeeded(), }); } else { patchDatabaseCpuIfNeeded(); } return; } if (!hasBillingPayload) { patchDatabaseCpuIfNeeded(); return; } setPendingUpgradePayload(payload); calculateUpgradeCostMutation.mutate(payload); }; const confirmUpgradeApply = () => { if (!pendingUpgradePayload || !upgradeCostData) return; if (upgradeCostData.proratedAmount > 0) { createUpgradeInvoiceMutation.mutate(pendingUpgradePayload); } else { scaleMutation.mutate(pendingUpgradePayload, { onSuccess: () => patchDatabaseCpuIfNeeded(), }); } }; const workloadStorageConfig = (): { label: string; value: string; setValue: (v: string) => void; minGi: number; maxGi: number; currentGi: number; } | null => { if (!app) return null; switch (scaleWorkload) { case 'app': if (!storageUsage?.appStorage) return null; return { label: 'Application volume', value: appStorageSize, setValue: setAppStorageSize, minGi: parseInt((app.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2, maxGi: 100, currentGi: storageUsage.appStorage.allocatedGi, }; case 'database': if (app.databaseType === 'none' || !storageUsage?.database) return null; return { label: 'Database volume', value: dbStorageSize, setValue: setDbStorageSize, minGi: parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1, maxGi: 500, currentGi: storageUsage.database.allocatedGi, }; case 'redis': if (!app.enableRedis || !storageUsage?.redisStorage) return null; return { label: 'Redis volume', value: redisStorageSize, setValue: setRedisStorageSize, minGi: Math.max(1, Math.round(storageUsage.redisStorage.allocatedGi)), maxGi: 100, currentGi: storageUsage.redisStorage.allocatedGi, }; case 'rabbitmq': if (!app.enableRabbitmq || !storageUsage?.rabbitmqStorage) return null; return { label: 'RabbitMQ volume', value: rabbitmqStorageSize, setValue: setRabbitmqStorageSize, minGi: Math.max(2, Math.round(storageUsage.rabbitmqStorage.allocatedGi)), maxGi: 100, currentGi: storageUsage.rabbitmqStorage.allocatedGi, }; default: return null; } }; const previewMutation = useMutation({ mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data), onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => { const targetUrl = data.ingressUrl || data.url; window.open(targetUrl, '_blank'); if (data.ingressUrl) { notify.success(ad.previewHttps); } else { notify.success(ad.previewPort.replace('{port}', String(data.nodePort))); } }, onError: () => notify.error(ad.previewFailed), }); 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] }); notify.success(ad.sourceUploaded); setUploadProgress(0); }, onError: () => { notify.error(ad.uploadFailed); setUploadProgress(0); }, }); const dbUploadMutation = useMutation({ mutationFn: (file: File) => { const formData = new FormData(); formData.append('file', file); return api.post(`/applications/${appId}/db-upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); }, onSuccess: (res) => { const data = res.data; setDbRestoreLogs(data.logs || null); if (data.success) { notify.success(ad.dbRestored); } else { notify.error(ad.dbRestoreFailed); } }, onError: (err: any) => { notify.error(err, ad.dbUploadFailed); setDbRestoreLogs(null); }, }); const handleFileUpload = useCallback((file: File) => { if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) { notify.error(ad.fileZipOnly); return; } if (file.size > MAX_SOURCE_ARCHIVE_BYTES) { notify.error(ad.fileMax10); 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); }, []); const handleDbFileUpload = useCallback((file: File) => { if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) { notify.error(ad.dbInvalidFile); return; } if (file.size > 500 * 1024 * 1024) { notify.error(ad.fileMax500); return; } setDbRestoreLogs(null); dbUploadMutation.mutate(file); }, [dbUploadMutation]); const handleDbDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDraggingDb(false); const file = e.dataTransfer.files[0]; if (file) handleDbFileUpload(file); }, [handleDbFileUpload]); const handleDbDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDraggingDb(true); }, []); const handleDbDragLeave = useCallback(() => { setIsDraggingDb(false); }, []); const copyToClipboard = useCallback((text: string, field: string) => { navigator.clipboard.writeText(text); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); }, []); if (isLoading || !app) { return (
{[1,2,3,4,5].map(i =>
)}
{[1,2,3].map(i =>
)}
); } if (isManagedProduct(app)) { return null; } 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 hasPaidAccess = !app.billingCycle || (app.lifecycleStatus === 'active' && (!app.planExpiresAt || new Date(app.planExpiresAt) > new Date())); const handleDelete = async () => { const ok = await confirm({ title: ad.deleteAppTitle.replace('{name}', app.name), message: ad.deleteAppMessage + (app.planExpiresAt && new Date(app.planExpiresAt) > new Date() ? ad.deleteAppCredit : ''), confirmText: t.common.delete, variant: 'danger', }); if (ok) deleteApplication(appId); }; const pageLocked = isAnyDeleting; // The host the app is actually reachable on right now: the verified custom // domain when present, otherwise the platform-assigned subdomain. Single source // of truth so every place that shows "the app's domain" stays in sync. const platformHost = `${app.subdomain}.${domainInfo?.platformDomain || 'apps.abrban.com'}`; const currentDomain = app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : platformHost; const copyDomain = (domain: string) => { navigator.clipboard.writeText(domain); notify.success(ad.copied); }; return ( <>
{/* Header */}

{app.name}

{statusLabel(latestStatus)}

{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '} {currentDomain}

{!hasDeployments && ( )} {hasDeployments && ( <> {isStopped ? ( ) : isRunning ? ( ) : null} {isRunning && ( )} {!isInProgress && hasPaidAccess && isApplicationProduct(app) && ( )} {isRunning && ( )} {app.enableElasticsearch && ( {ad.logs} )} )}
{/* Renewal Banner for Expired/Suspended Apps */} {needsRenewal && (

{app.lifecycleStatus === 'pending_deletion' ? ad.scheduledForDeletion : ad.suspendedPayment}

{app.lifecycleStatus === 'pending_deletion' ? ad.willBeDeletedOn.replace('{date}', app.scheduledDeletionAt ? new Date(app.scheduledDeletionAt).toLocaleString(locale) : ad.soon) : ad.planExpiredRestore}

)} {/* Expiring Soon Warning */} {!needsRenewal && isExpiringSoon && app.planExpiresAt && (

{ad.planExpiringSoon}

{ad.planExpiresEarly.replace('{date}', new Date(app.planExpiresAt).toLocaleString(locale))}

)} {/* Renewal Modal */} {showRenewalModal && (

{ad.renewApplication}

{ad.selectBillingCycle.replace('{name}', app.name)}

{/* Wallet Balance */}
{ad.walletBalance}
{walletData?.balance?.toLocaleString('en-US') || 0} {ad.toman}
{/* Billing Cycle Selection */}
{renewalCostData?.costs && ( <> )}
{/* Insufficient Balance Warning */} {renewalCostData?.costs && walletData && ( (() => { const cost = selectedCycle === 'hourly' ? renewalCostData.costs.hourly : selectedCycle === 'monthly' ? renewalCostData.costs.monthly : renewalCostData.costs.yearly; if (walletData.balance < cost) { return (

{ad.walletShortBy.replace('{n}', (cost - walletData.balance).toLocaleString('en-US'))}

); } return null; })() )} {/* Coupon */}
setRenewCoupon(e.target.value.toUpperCase())} />
{/* Actions */}
)} {/* Upgrade Confirmation Modal */} {showUpgradeConfirm && upgradeCostData && (

{ad.confirmResourceUpgrade}

{upgradeCostData.proratedAmount > 0 ? 'This upgrade requires payment for the remaining billing period.' : 'No additional cost for this change.'}

{/* Cost Summary */}
{ad.currentHourlyCost} {upgradeCostData.currentCost.hourly.toLocaleString('en-US')} {ad.tomanPerHour}
{ad.newHourlyCost} {upgradeCostData.newCost.hourly.toLocaleString('en-US')} {ad.tomanPerHour}
{ad.remainingHours} {ad.hoursUnit.replace('{n}', String(upgradeCostData.remainingHours))}
{ad.proratedAmount} {upgradeCostData.proratedAmount.toLocaleString('en-US')} {ad.toman}
{/* Wallet Balance */}
{ad.walletBalance}
{walletData?.balance?.toLocaleString('en-US') || 0} {ad.toman}
{/* Insufficient Balance Warning */} {walletData && upgradeCostData.proratedAmount > walletData.balance && (

{ad.walletShortBy.replace('{n}', (upgradeCostData.proratedAmount - walletData.balance).toLocaleString('en-US'))}

)} {/* Actions */}
)} {/* Status & Config */}

{ad.configuration}

{ad.runtime}
{app.runtime} {app.runtime === 'nodejs' && app.runtimeVersion ? ` v${app.runtimeVersion}` : ''} {app.runtime === 'wordpress' && app.runtimeVersion ? ` v${app.runtimeVersion}` : ''} {(app.runtime === 'laravel' || app.runtime === 'wordpress') && app.phpVersion ? ` — PHP ${app.phpVersion}` : ''}
{ad.database}
{app.databaseType} {app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''}
{ad.replicas}
{app.replicas}
{ad.cpu}
{app.cpuRequest} / {app.cpuLimit}
{ad.memory}
{app.memoryRequest} / {app.memoryLimit}
{ad.port}
{app.port}
{isAdmin && app.clusterId && (
{ad.cluster}
{clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'}
)} {isAdmin && app.poolId && (
{ad.pool}
{pools.find((p) => p.id === app.poolId)?.name || 'Unknown'}
)} {app.latestImageTag && (
{ad.image}
{app.latestImageTag}
)}

{ad.deploymentHistory}

{deployments.length === 0 ? (

{ad.noDeployments}

{ad.uploadToStart}

) : (
{deployments.slice(0, 10).map((d) => (

{d.version || d.imageTag}

{new Date(d.createdAt).toLocaleString(locale)}

{d.errorMessage && (

{d.errorMessage}

)}
{statusLabel(d.status)}
))}
)}
{/* Source Code Upload */}

{ad.sourceCode}

{app.codePath ? (

{ad.sourceCodeUploaded}

{app.codePath.split('/').pop()}

) : app.gitUrl ? (

{ad.gitConnected}

{app.gitUrl}

{app.gitBranch && ( {app.gitBranch} )} {(app.hasGitToken ?? app.gitToken) && ( {ad.private} )}
) : null}
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' : ''} `} > { const file = e.target.files?.[0]; if (file) handleFileUpload(file); e.target.value = ''; }} /> {uploadMutation.isPending ? (

Uploading... {uploadProgress}%

) : (

{app.codePath ? 'Upload new version' : 'Upload your project source code'}

{ad.dragDrop} .zip {ad.fileHereBrowse}

{ad.maxSize10gb}

)}
{/* Custom Domain */}

{ad.domain}

{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( )}
{/* Application domain — the host the app is reachable on right now (verified custom domain, else the platform subdomain). Click to copy. */}

{ad.appDomain}

{ad.active}
{/* Custom domain - verified */} {app.customDomain && app.customDomainStatus === 'verified' && (

{ad.customDomain}

SSL Active — Verified on{' '} {app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString(locale) : ''}

)} {/* Custom domain - pending DNS */} {app.customDomain && app.customDomainStatus === 'pending_dns' && (

{ad.customDomainPending}

{app.customDomain}

{/* DNS Instructions */}

{ad.dnsSetupGuide}

{ad.dnsStep1}

{ad.dnsStep2}

{ad.dnsStep3a}CNAME{ad.dnsStep3b}

{ad.nameHost}@ {ad.orText} www

{ad.typeColon}CNAME

{ad.valueColon}{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.abrban.com`}

{ad.dnsStep4a}www{ad.dnsStep4b}

{ad.dnsStep5}

{ad.dnsStep6a}"{ad.verifyDns}"{ad.dnsStep6b}

{domainInfo?.fullPlatformUrl && (

{ad.cnameTarget}

{domainInfo.fullPlatformUrl}
)}
)} {/* Domain setup form */} {showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (

{ad.setUpCustomDomain}

{domainPriceData && domainPriceData.monthlyPrice > 0 && (

{ad.customDomainFee}{domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman / month

{ad.feeIncludedNote}

)}
setCustomDomainInput(e.target.value)} placeholder={ad.domainPlaceholder} className="input-field flex-1 font-mono text-sm" />
)}
{/* Database Info & Dump Upload */} {app.databaseType !== 'none' && (

{ad.database}{app.databaseType}

{/* Connection Info */}

{ad.connectionInfoInternal}

{[ { label: 'Host', value: `${app.name}-db`, field: 'host' }, { label: 'Port', value: app.databaseType === 'postgresql' ? '5432' : '3306', field: 'port' }, { label: 'Database', value: app.name.replace(/-/g, '_'), field: 'database' }, { label: 'Username', value: app.dbUsername || 'appuser', field: 'username' }, ].map(({ label, value, field }) => (
{label}
{value}
))} {/* Password row with show/hide */}
{ad.password}
{showDbPassword ? (app.dbPassword || '—') : '••••••••••••'}

{ad.dbOnlyInternal}

{/* DB Dump Upload */}

{ad.restoreDatabaseDump}

dbFileInputRef.current?.click()} className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${isDraggingDb ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50' } ${dbUploadMutation.isPending ? 'pointer-events-none opacity-60' : ''} `} > { const file = e.target.files?.[0]; if (file) handleDbFileUpload(file); e.target.value = ''; }} /> {dbUploadMutation.isPending ? (

{ad.restoringDatabase}

{ad.thisMayTake}

) : (

{ad.uploadSqlToRestore}

{ad.dragDrop} .sql {ad.fileHereBrowse}

{ad.maxSize500mb}

)}
{/* Restore Logs */} {dbRestoreLogs && (

{ad.restoreOutput}

                {dbRestoreLogs}
              
)}
)} {/* Optional Service Credentials */} {(app.enableRedis || app.enableRabbitmq) && (

{ad.serviceCredentials}

{app.enableRedis && (

{ad.workload}

{[ { label: 'Host', value: serviceCredentials?.redis?.host || `${app.name}-redis`, field: 'redis-host' }, { label: 'Port', value: String(serviceCredentials?.redis?.port || 6379), field: 'redis-port' }, { label: 'Password', value: serviceCredentials?.redis?.password || '', field: 'redis-password', secret: true, }, { label: 'URL', value: serviceCredentials?.redis?.url || '', field: 'redis-url', secret: true }, ].map(({ label, value, field, secret }) => (
{label}
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
))}
)} {app.enableRabbitmq && (

{ad.workload}

{[ { label: 'Host', value: serviceCredentials?.rabbitmq?.host || `${app.name}-rabbitmq`, field: 'rabbit-host' }, { label: 'AMQP Port', value: String(serviceCredentials?.rabbitmq?.amqpPort || 5672), field: 'rabbit-amqp-port' }, { label: 'Management Port', value: String(serviceCredentials?.rabbitmq?.managementPort || 15672), field: 'rabbit-mgmt-port' }, { label: 'Username', value: serviceCredentials?.rabbitmq?.username || 'appuser', field: 'rabbit-user' }, { label: 'Password', value: serviceCredentials?.rabbitmq?.password || '', field: 'rabbit-password', secret: true, }, { label: 'AMQP URL', value: serviceCredentials?.rabbitmq?.amqpUrl || '', field: 'rabbit-amqp-url', secret: true }, { label: 'Management URL', value: serviceCredentials?.rabbitmq?.managementUrl || '', field: 'rabbit-mgmt-url' }, ].map(({ label, value, field, secret }) => (
{label}
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
))}
)}
{!app.latestImageTag && (

{ad.serviceCredsAfterDeploy}

)}
)} {app && } {/* Resource Monitoring & Scaling */}

{ad.resourcesScaling}

{showResources && (
{/* Live Metrics */} {resourcesLoading ? (
{ad.loadingMetrics}
) : resourceUsage ? ( <> {resourceUsage.loggingNote && (

{resourceUsage.loggingNote}

)} {(resourceUsage.workloads && resourceUsage.workloads.length > 0 ? resourceUsage.workloads : resourceUsage.configured ? [ { key: 'app' as const, title: ad.productTypeApp, deploymentName: app?.name || '', configured: resourceUsage.configured, pods: resourceUsage.pods, metrics: resourceUsage.metrics, sidecars: undefined, }, ] : [] ).map((w) => (

{w.title}

{w.deploymentName}

{ad.replicas}

{w.configured.readyReplicas}/{w.configured.replicas}

{ad.ready}

{ad.pods}

{w.pods.length}

{w.pods.filter((p) => p.ready).length} {ad.ready}

{ad.metrics}

{w.metrics.length > 0 ? : }

{w.metrics.length > 0 ? 'Live' : 'Waiting…'}

{ad.cpuColon} {w.configured.cpuRequest} → {w.configured.cpuLimit}
{ad.memoryColon} {w.configured.memoryRequest} → {w.configured.memoryLimit}
{w.metrics.length > 0 && (

{ad.liveUsage}

{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 (

{metric.name}

{ad.cpu} {cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)
80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${cpuPercent}%` }} />
{ad.memory} {memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)
80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${memPercent}%` }} />
); })}
)} {w.pods.length > 0 && (

{ad.pods}

{w.pods.map((pod) => ( ))}
{ad.name} {ad.status} {ad.ready} {ad.restarts}
{pod.name} {pod.status} {pod.ready ? : } {pod.restarts}
)} {w.sidecars && w.sidecars.length > 0 && (
{ad.sidecars} {w.sidecars.map((s) => ( {s.name} (CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'}) ))}
)}
))} {/* Storage Usage Section */}

{ad.storageUsage}

{storageUsageLoading ? (
{ad.loadingStorageMetrics}
) : storageUsage ? (
{/* Database Storage */} {storageUsage.database && (
{ad.databaseVolume} {storageUsage.database.usedGi.toFixed(2)} GiB / {storageUsage.database.allocatedGi.toFixed(1)} GiB
80 ? 'bg-red-500' : storageUsage.database.usedPercent > 50 ? 'bg-yellow-500' : 'bg-blue-500' }`} style={{ width: `${Math.min(storageUsage.database.usedPercent, 100)}%` }} />
Used {storageUsage.database.usedGi.toFixed(2)} GiB Free ~{storageUsage.database.availableGi.toFixed(2)} GiB

{ad.expandDiskNote}

)} {storageUsage.appStorage && (
{app?.runtime === 'wordpress' ? 'wp-content volume' : 'Application volume'} {storageUsage.appStorage.usedGi.toFixed(2)} GiB / {storageUsage.appStorage.allocatedGi.toFixed(1)} GiB
80 ? 'bg-red-500' : storageUsage.appStorage.usedPercent > 50 ? 'bg-yellow-500' : 'bg-green-500' }`} style={{ width: `${Math.min(storageUsage.appStorage.usedPercent, 100)}%` }} />
Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB

{ad.expandDiskNote}

)} {storageUsage.redisStorage && app?.enableRedis && (
{ad.redisVolume} {storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB
80 ? 'bg-red-500' : 'bg-amber-500'}`} style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }} />

Allocated {storageUsage.redisStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.

)} {storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
{ad.rabbitmqVolume} {storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB
80 ? 'bg-red-500' : 'bg-violet-500'}`} style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }} />

Allocated {storageUsage.rabbitmqStorage.allocatedRaw} — expand in Adjust CPU / memory & storage.

)} {!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (

{ad.noStorageData}

)}
) : (

{ad.storageMetricsUnavailable}

)}
{/* Scaling Controls */}

{ad.adjustCpuMemStorage}

{ad.pickComponentNote}

patchResourceForm({ cpuRequest: e.target.value })} className="input-field text-sm" placeholder="100m" />
patchResourceForm({ cpuLimit: e.target.value })} className="input-field text-sm" placeholder="500m" />
patchResourceForm({ memoryRequest: e.target.value })} className="input-field text-sm" placeholder="128Mi" />
patchResourceForm({ memoryLimit: e.target.value })} className="input-field text-sm" placeholder="512Mi" />
{scaleWorkload === 'app' && (
{resourceForm.replicas}
)}
{(() => { const storageCfg = workloadStorageConfig(); if (!storageCfg) return null; return (

Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with Apply changes.

{ const val = Math.max( storageCfg.minGi + 1, Math.min(storageCfg.maxGi, parseInt(e.target.value, 10) || storageCfg.minGi + 1), ); storageCfg.setValue(String(val)); }} className="w-14 text-center py-1.5 border-x border-gray-300 text-xs font-semibold focus:outline-none" />
GiB
); })()}
) : (

{isStopped ? 'Application is stopped. Start it to see resources.' : 'No resource data available yet.'}

)}
)}
{/* Snapshots & Rollback */}

{ad.rollbackSnapshots}

{showSnapshots && (
{/* Tab switcher */}
{/* ─── K8s Revisions Tab ─── */} {snapshotTab === 'revisions' && (

{ad.instantRollback} {ad.revisionsHelmNote}

{revisionsLoading ? (
{ad.loadingRevisions}
) : !revisionData?.revisions?.length ? (

{ad.noRevisions}

{ad.revisionsAfterDeploy}

) : (
{revisionData.revisions.map((rev) => (
{ad.revision} {rev.revision} {rev.isCurrent && ( {ad.current} )}

{rev.changeCause}

{new Date(rev.createdAt).toLocaleString(locale)}

{!rev.isCurrent && ( )}
))}
)}
)} {/* ─── File Snapshots Tab ─── */} {snapshotTab === 'snapshots' && (
{/* Download current live state */}

{ad.downloadCurrentState}

{ad.downloadCurrentNote}

{app.codePath && ( )} {app.runtime?.toLowerCase() === 'wordpress' && ( )} {app.databaseType && app.databaseType.toLowerCase() !== 'none' && ( )}

{ad.fullSnapshots} {ad.snapshotsIncludeNote}

{/* Snapshot list */} {snapshotsLoading ? (
{ad.loadingSnapshots}
) : snapshots.length === 0 ? (

{ad.noSnapshots}

{ad.snapshotsAutoNote}

) : (
{snapshots.map((snap) => (

{snap.label || 'Untitled'}

{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'} {snap.status === 'in_progress' ? 'Creating...' : snap.status}

{new Date(snap.createdAt).toLocaleString(locale)} {snap.imageTag && image: {snap.imageTag.split(':').pop()?.slice(0, 12)}}

{/* Artifact sizes */} {snap.status === 'completed' && (
{snap.appArchivePath && ( Source: {formatBytes(snap.appArchiveSize)} )} {snap.wpContentArchivePath && ( wp-content: {formatBytes(snap.wpContentSize)} )} {snap.dbDumpPath && ( DB: {formatBytes(snap.dbDumpSize)} )}
)} {snap.errorMessage && (

{snap.errorMessage}

)}
{/* Actions */} {snap.status === 'completed' && (
{snap.appArchivePath && ( )} {snap.wpContentArchivePath && ( )} {snap.dbDumpPath && ( )}
)}
))}
)}

{ad.maxSnapshots}

)}
)}
); }