'use client'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import api from '@/lib/api'; import { toast } from 'react-toastify'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; import NextLink from 'next/link'; 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, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; import { BuildProgressModal } from '@/components/build-progress-modal'; /** Matches backend multipart limit for POST /applications/:id/upload */ const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3; const statusColors: Record = { running: 'badge-green', pending: 'badge-yellow', building: 'badge-blue', deploying: 'badge-blue', failed: 'badge-red', build_failed: 'badge-red', stopped: 'badge-gray', cancelled: 'badge-gray', }; /** * Parse CPU value to millicores (e.g. "100m" → 100, "1" → 1000, "250n" → 0.00025) */ function parseCpuToMillicores(cpu: string): number { if (!cpu || cpu === '0') return 0; if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000; if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000; if (cpu.endsWith('m')) return parseFloat(cpu); return parseFloat(cpu) * 1000; } /** * Parse memory value to MiB (e.g. "128Mi" → 128, "1Gi" → 1024, "131072Ki" → 128) */ function parseMemoryToMi(memory: string): number { if (!memory || memory === '0') return 0; if (memory.endsWith('Ki')) return parseFloat(memory) / 1024; if (memory.endsWith('Mi')) return parseFloat(memory); if (memory.endsWith('Gi')) return parseFloat(memory) * 1024; if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024; // raw bytes return parseFloat(memory) / (1024 * 1024); } export default function AppDetailPage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const confirm = useConfirm(); const appId = params.id as string; const [showLogs, setShowLogs] = useState(false); const [logTab, setLogTab] = useState<'pod' | 'build'>('pod'); const fileInputRef = useRef(null); const logsEndRef = 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 [showDbDiskExpand, setShowDbDiskExpand] = useState(false); const [dbStorageSize, setDbStorageSize] = useState('1'); 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 [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false); const [upgradeCostData, setUpgradeCostData] = useState<{ proratedAmount: number; remainingHours: number; currentCost: { hourly: number }; newCost: { hourly: number }; } | null>(null); // ── Custom Domain ────────────────────────────────── const [showDomainSetup, setShowDomainSetup] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); const [accessTarget, setAccessTarget] = useState('database'); const [accessDuration, setAccessDuration] = useState(60); const [showAccessSecret, setShowAccessSecret] = useState(false); const [accessNow, setAccessNow] = useState(() => Date.now()); const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], 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({ queryKey: ['deployments', appId], queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data), refetchInterval: 5000, // Poll for status updates }); const { data: logsData } = useQuery<{ logs: string }>({ queryKey: ['logs', appId], queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data), enabled: showLogs && logTab === 'pod', refetchInterval: showLogs && logTab === 'pod' ? 3000 : false, }); const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({ queryKey: ['build-logs', appId], queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data), enabled: showLogs && logTab === 'build', refetchInterval: showLogs && logTab === 'build' ? 5000 : false, }); const { data: resourceUsage, isLoading: resourcesLoading } = useQuery({ 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), }); const { data: pools = [] } = useQuery({ queryKey: ['pools-public'], queryFn: () => api.get('/clusters/pools/public').then((r) => r.data), }); // 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'); const [showAppStorageExpand, setShowAppStorageExpand] = useState(false); useEffect(() => { if (app?.appStorageSize) { const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2; setAppStorageSize(String(sizeNum)); } }, [app?.appStorageSize]); const resizeAppStorageMutation = useMutation({ mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }), onSuccess: (res) => { if (res.data.success) { toast.success(res.data.message || 'App storage expanded!'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] }); setShowAppStorageExpand(false); } else { toast.error(res.data.message || 'Failed to expand storage'); } }, onError: (err: any) => { toast.error(err.response?.data?.message || 'Failed to resize app storage'); }, }); const resizeDbMutation = useMutation({ mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }), onSuccess: (res) => { 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'); } }, onError: (err: any) => { toast.error(err.response?.data?.message || 'Failed to resize database storage'); }, }); // ─── Billing & Renewal ────────────────────────────── const { data: walletData } = useQuery<{ balance: number }>({ queryKey: ['wallet'], 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 }), onSuccess: (res) => { toast.success(res.data.message || 'Application renewed successfully!'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['wallet'] }); setShowRenewalModal(false); }, onError: (err: any) => { toast.error(err.response?.data?.message || 'Failed to renew application'); }, }); const createRenewalInvoiceMutation = useMutation({ mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data), onSuccess: (invoice) => { toast.success('Invoice created. Choose how you want to pay.'); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowRenewalModal(false); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => { toast.error(err.response?.data?.message || '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), enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')), }); 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: () => { toast.success('Domain set. Please configure your DNS records.'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); setCustomDomainInput(''); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to set domain'), }); const verifyDnsMutation = useMutation({ mutationFn: () => api.post(`/applications/${appId}/domain/verify`), onSuccess: (res) => { if (res.data.verified) { toast.success('Domain verified successfully!'); } else { toast.warning(res.data.message || 'DNS is not ready yet. Please try again later.'); } queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, onError: (err: any) => toast.error(err.response?.data?.message || 'DNS verification failed'), }); const removeDomainMutation = useMutation({ mutationFn: () => api.delete(`/applications/${appId}/domain`), onSuccess: () => { toast.success('Custom domain removed'); queryClient.invalidateQueries({ queryKey: ['application', appId] }); refetchDomainInfo(); }, onError: (err: any) => toast.error(err.response?.data?.message || '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) => { toast.success(res.data.message || 'Rollback completed'); queryClient.invalidateQueries({ queryKey: ['revisions', appId] }); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Rollback failed'), }); const handleRevisionRollback = async (rev: K8sRevision) => { const ok = await confirm({ title: `Rollback to Revision ${rev.revision}?`, message: `This will rollback the Helm release to revision ${rev.revision}.\n${rev.changeCause ? `\nDescription: ${rev.changeCause}` : ''}\n\nNo rebuild needed — takes effect in seconds.`, confirmText: 'Rollback', variant: 'warning', }); if (ok) revisionRollbackMutation.mutate(rev.revision); }; const createSnapshotMutation = useMutation({ mutationFn: () => api.post(`/snapshots/applications/${appId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); toast.success('Snapshot creation started'); }, onError: () => toast.error('Failed to create snapshot'), }); const rollbackMutation = useMutation({ mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`), onSuccess: (res) => { const details = res.data.details || []; toast.success('Rollback completed:\n' + details.join('\n')); queryClient.invalidateQueries({ queryKey: ['application', appId] }); queryClient.invalidateQueries({ queryKey: ['deployments', appId] }); }, onError: () => toast.error('Rollback failed'), }); const deleteSnapshotMutation = useMutation({ mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['snapshots', appId] }); toast.success('Snapshot deleted'); }, onError: () => toast.error('Failed to delete snapshot'), }); const handleRollback = async (snap: AppSnapshot) => { const ok = await confirm({ title: `Rollback to "${snap.label}"?`, message: 'This will restore:\n• App deployment via K8s revision (instant)\n• wp-content files (WordPress)\n• Database dump\n\nThe current state will be overwritten.', confirmText: 'Rollback', variant: 'warning', }); if (ok) rollbackMutation.mutate(snap.id); }; const handleDeleteSnapshot = async (snap: AppSnapshot) => { const ok = await confirm({ title: 'Delete snapshot?', message: `Delete "${snap.label}"? The archived files will be permanently removed.`, confirmText: '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(() => toast.error(`Failed to download ${artifact}`)); }; const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => { // Prevent duplicate downloads if (downloadingArtifact) { toast.warn('A download is already in progress. Please wait.'); 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'; toast.info(`Downloading ${artifactName}... This may take up to 15 minutes for large files.`); 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); toast.success(`${artifactName} downloaded successfully!`); }) .catch((err) => { clearTimeout(timeoutId); if (err.name === 'AbortError') { toast.error(`Download timed out after 15 minutes. Try again or check server logs.`); } else { toast.error(`Failed to download current ${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 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: (res) => { queryClient.invalidateQueries({ queryKey: ['applications'] }); queryClient.invalidateQueries({ queryKey: ['resource-credits'] }); if (res.data?.resourceCredit) { toast.success('Application deleted. Prepaid resources are on your dashboard.'); } else { 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.post(`/billing/applications/${appId}/upgrade`, data), onSuccess: (res) => { setResourceFormDirty(false); invalidateAll(); queryClient.invalidateQueries({ queryKey: ['resources', appId] }); queryClient.invalidateQueries({ queryKey: ['wallet'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); const paidAmount = res.data.paidAmount || 0; if (paidAmount > 0) { toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`); } else { toast.success('Resources updated successfully!'); } }, 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: () => { setResourceFormDirty(false); 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 }) => api.post(`/billing/applications/${appId}/upgrade/calculate`, data), onSuccess: (res) => { setUpgradeCostData(res.data); setShowUpgradeConfirm(true); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'), }); const createUpgradeInvoiceMutation = useMutation({ mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) => api.post(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data), onSuccess: (invoice) => { toast.success('Invoice created. Choose how you want to pay.'); queryClient.invalidateQueries({ queryKey: ['invoices'] }); setShowUpgradeConfirm(false); setUpgradeCostData(null); router.push(`/dashboard/invoices?invoice=${invoice.id}`); }, onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'), }); // Handler: app uses billing upgrade path when subscribed; other workloads patch directly. const handleScaleResources = () => { 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; } calculateUpgradeCostMutation.mutate(resourceForm); }; 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 accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = []; if (app?.databaseType && app.databaseType !== 'none') { accessTargetOptions.push({ value: 'database', label: 'Database' }); } if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' }); if (app?.enableRabbitmq) { accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' }); accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' }); } const hasAccessTargets = accessTargetOptions.length > 0; useEffect(() => { if (!hasAccessTargets) return; if (!accessTargetOptions.some((o) => o.value === accessTarget)) { setAccessTarget(accessTargetOptions[0].value); } }, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]); const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery({ queryKey: ['access-grants', appId], queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data), enabled: hasAccessTargets && !!app?.latestImageTag, refetchInterval: 30000, }); useEffect(() => { if (!accessGrants.some((g) => g.status === 'active')) return; const t = setInterval(() => setAccessNow(Date.now()), 1000); return () => clearInterval(t); }, [accessGrants]); const createAccessMutation = useMutation({ mutationFn: () => api.post(`/applications/${appId}/access`, { target: accessTarget, durationMinutes: accessDuration, }).then((r) => r.data), onSuccess: () => { refetchAccessGrants(); toast.success('Temporary external access enabled'); }, onError: (err: any) => { toast.error(err.response?.data?.message || 'Failed to enable access'); }, }); const revokeAccessMutation = useMutation({ mutationFn: (grantId: string) => api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data), onSuccess: () => { refetchAccessGrants(); toast.success('Access revoked'); }, onError: () => toast.error('Failed to revoke access'), }); const accessTargetLabel = (target: ServiceAccessTarget) => accessTargetOptions.find((o) => o.value === target)?.label || target; const formatAccessCountdown = (expiresAt: string) => { const ms = new Date(expiresAt).getTime() - accessNow; if (ms <= 0) return 'Expired'; const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; if (h > 0) return `${h}h ${m}m ${s}s`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; }; 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 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) { toast.success('Database restored successfully!'); } else { toast.error(data.message || 'Database restore failed'); } }, onError: (err: any) => { toast.error(err.response?.data?.message || 'Failed to upload database dump'); setDbRestoreLogs(null); }, }); 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 > MAX_SOURCE_ARCHIVE_BYTES) { toast.error('File size must be at most 10 GB'); 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')) { toast.error('Please upload a .sql, .dump, or .gz file'); return; } if (file.size > 500 * 1024 * 1024) { toast.error('File size must be less than 500MB'); 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 =>
)}
); } 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: `Delete "${app.name}"?`, message: 'This will permanently remove all Kubernetes resources, data, and deployment records.\n\n' + (app.planExpiresAt && new Date(app.planExpiresAt) > new Date() ? 'Your remaining paid resources will appear on the dashboard for use on a new app at no extra charge.' : ''), confirmText: 'Delete', variant: 'danger', }); if (ok) deleteMutation.mutate(); }; return (
{/* Header */}

{app.name}

{latestStatus}

{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '} {app.customDomain && app.customDomainStatus === 'verified' ? {app.customDomain} : {app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} }

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

{app.lifecycleStatus === 'pending_deletion' ? 'Application Scheduled for Deletion!' : 'Application Suspended — Payment Required'}

{app.lifecycleStatus === 'pending_deletion' ? `This application will be permanently deleted on ${app.scheduledDeletionAt ? new Date(app.scheduledDeletionAt).toLocaleString() : 'soon'}. Renew now to prevent data loss.` : 'Your plan has expired. Renew to restore service access.'}

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

Plan Expiring Soon

Your plan expires on {new Date(app.planExpiresAt).toLocaleString()}. Renew early to avoid service interruption.

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

Renew Application

Select a billing cycle to renew "{app.name}"

{/* Wallet Balance */}
Wallet Balance
{walletData?.balance?.toLocaleString() || 0} 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 (

Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.

); } return null; })() )} {/* Actions */}
)} {/* Upgrade Confirmation Modal */} {showUpgradeConfirm && upgradeCostData && (

Confirm Resource Upgrade

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

{/* Cost Summary */}
Current hourly cost {upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
New hourly cost {upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
Remaining hours in period {upgradeCostData.remainingHours} hours
Prorated amount to pay {upgradeCostData.proratedAmount.toLocaleString()} Toman
{/* Wallet Balance */}
Wallet Balance
{walletData?.balance?.toLocaleString() || 0} Toman
{/* Insufficient Balance Warning */} {walletData && upgradeCostData.proratedAmount > walletData.balance && (

Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.

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

Configuration

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}` : ''}
Database
{app.databaseType} {app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''}
Replicas
{app.replicas}
CPU
{app.cpuRequest} / {app.cpuLimit}
Memory
{app.memoryRequest} / {app.memoryLimit}
Port
{app.port}
{app.clusterId && (
Cluster
{clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'}
)} {app.poolId && (
Pool
{pools.find((p) => p.id === app.poolId)?.name || 'Unknown'}
)} {app.latestImageTag && (
Image
{app.latestImageTag}
)}

Deployment History

{deployments.length === 0 ? (

No deployments yet

Upload source code and click Deploy to get started

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

{d.version || d.imageTag}

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

{d.errorMessage && (

{d.errorMessage}

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

Source Code

{app.codePath ? (

Source code uploaded

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

) : app.gitUrl ? (

Git repository connected

{app.gitUrl}

{app.gitBranch && ( {app.gitBranch} )} {app.gitToken && ( 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'}

Drag & drop a .zip file here, or click to browse

Max size: 10 GB

)}
{/* Custom Domain */}

Domain

{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && ( )}
{/* Platform domain (always shown) */}

Platform Domain

{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}

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

Custom Domain

{app.customDomain}

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

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

Custom Domain — Pending DNS Verification

{app.customDomain}

{/* DNS Instructions */}

DNS Setup Guide

1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)

2. Go to DNS management for your domain

3. Add a CNAME record:

Name/Host: @ or www

Type: CNAME

Value: {domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.cloudhost.ir`}

4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare), or use www instead.

5. Wait 5–30 minutes for DNS propagation (up to 48 hours in some cases)

6. Click the "Verify DNS" button above

{domainInfo?.fullPlatformUrl && (

CNAME Target:

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

Set Up Custom Domain

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

Custom domain fee: {domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman / month

This fee is included in the total cost calculation.

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

Database {app.databaseType}

{/* Connection Info */}

Connection Info (Internal Cluster)

{[ { 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 */}
Password
{showDbPassword ? (app.dbPassword || '—') : '••••••••••••'}

Database is only accessible within the cluster. Not exposed externally.

{/* Database Storage Management */}

Database Storage

Current Size: {dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}
{ const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)); setDbStorageSize(String(val)); }} className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none" />
GB

Only expansion is allowed (shrinking is not possible)

{/* DB Dump Upload */}

Restore Database Dump

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

Restoring database...

This may take a few minutes

) : (

Upload SQL dump to restore

Drag & drop a .sql file here, or click to browse

Max size: 500MB

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

Restore Output

                {dbRestoreLogs}
              
)}
)} {/* Temporary External Access */} {hasAccessTargets && (

Temporary External Access

Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.

{!app?.latestImageTag ? (

Deploy the application first to enable external access.

) : ( <>
{[30, 60, 240].map((mins) => ( ))}
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (

No active external access sessions.

) : (
{accessGrants .filter((g) => g.status === 'active') .map((grant) => (
{accessTargetLabel(grant.target)} ends in {formatAccessCountdown(grant.expiresAt)}
Endpoint
{grant.host}:{grant.port}
{grant.connection.url && (
URL
{showAccessSecret ? grant.connection.url : '••••••••••••'}
)}
))}
)} )}
)} {/* Resource Monitoring & Scaling */}

Resources & Scaling

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

{resourceUsage.loggingNote}

)} {(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) => (

{w.title}

{w.deploymentName}

Replicas

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

ready

Pods

{w.pods.length}

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

Metrics

{w.metrics.length > 0 ? : }

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

CPU: {w.configured.cpuRequest} → {w.configured.cpuLimit}
Memory: {w.configured.memoryRequest} → {w.configured.memoryLimit}
{w.metrics.length > 0 && (

Live usage

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

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}%` }} />
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 && (

Pods

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

Storage Usage

{storageUsageLoading ? (
Loading storage metrics...
) : storageUsage ? (
{/* Database Storage */} {storageUsage.database && (
Database volume {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
{app?.databaseType !== 'none' && (
{showDbDiskExpand ? (
{ 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" />
GiB
) : ( )}

PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}

)}
)} {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
{/* Expand App Storage (all app types) */}
{showAppStorageExpand ? (
{ const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2)); setAppStorageSize(String(val)); }} className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none" />
GB
) : ( )}

Only expansion is allowed

)} {storageUsage.redisStorage && app?.enableRedis && (
Redis (optional) volume {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}

)} {storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
RabbitMQ (optional) volume {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}

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

No storage data available

)}
) : (

Storage metrics unavailable

)}
{/* Scaling Controls */}

Adjust CPU / memory

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.

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}
)}
) : (

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

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

Rollback & Snapshots

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

Instant rollback using Helm release revisions. Switches to a previous configuration in seconds — no rebuild needed. Up to 10 revisions are kept.

{revisionsLoading ? (
Loading revisions...
) : !revisionData?.revisions?.length ? (

No revisions available

Revisions appear after the first deployment.

) : (
{revisionData.revisions.map((rev) => (
Revision {rev.revision} {rev.isCurrent && ( Current )}

{rev.changeCause}

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

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

Download Current State

Download a copy of the current live files without creating a snapshot. Large files may take up to 15 minutes.

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

Full snapshots include source code, database dump, and wp-content. Use these to restore data or download backups. Auto-created before each deploy.

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

No snapshots yet

Snapshots are created automatically before each deploy, or you can create one manually.

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

{snap.label || 'Untitled'}

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

{new Date(snap.createdAt).toLocaleString()} {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 && ( )}
)}
))}
)}

Maximum 10 snapshots are kept. Older snapshots are automatically deleted.

)}
)}
{/* Logs — Pod & Build */}

Logs

{showLogs && logTab === 'pod' && ( Live (every 3s) )} {showLogs && logTab === 'build' && ( Auto-refresh (every 5s) )}
{showLogs && (
{/* Tab switcher */}
{/* Pod logs */} {logTab === 'pod' && (
                {logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
              
)} {/* Build logs */} {logTab === 'build' && (
{buildLogsData?.version && (
{buildLogsData.version} {buildLogsData.status}
)}
                  {buildLogsData?.buildLog || (
                    buildLogsData?.status === 'building'
                      ? 'Build in progress... Logs will appear when complete.'
                      : buildLogsData?.status === 'pending'
                        ? 'Build is pending...'
                        : buildLogsData?.status === 'no_deployment'
                          ? 'No deployments yet. Deploy your app to see build logs.'
                          : 'No build logs available for this deployment.'
                  )}
                
)}
)}
); }