Files
cloud-host/frontend/src/app/[lang]/dashboard/apps/[id]/page.tsx
T
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30

2767 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } 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<string, string> = {
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<string, string>)[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<HTMLInputElement>(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<string | null>(null);
const [dbRestoreLogs, setDbRestoreLogs] = useState<string | null>(null);
const dbFileInputRef = useRef<HTMLInputElement>(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<UpgradePayload | null>(null);
// ── Custom Domain ──────────────────────────────────
const [showDomainSetup, setShowDomainSetup] = useState(false);
const [customDomainInput, setCustomDomainInput] = useState('');
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
const { data: app, isLoading } = useQuery<Application>({
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<Deployment[]>({
queryKey: ['deployments', appId],
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
refetchInterval: 5000, // Poll for status updates
});
const { data: serviceCredentials } = useQuery<OptionalServiceCredentials>({
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<ResourceUsage>({
queryKey: ['resources', appId],
queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data),
enabled: showResources,
refetchInterval: showResources ? 5000 : false,
});
const { data: clusters = [] } = useQuery<ClusterPublic[]>({
queryKey: ['clusters-public'],
queryFn: () => api.get('/clusters/public').then((r) => r.data),
enabled: isAdmin,
});
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
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<StorageUsageData>({
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<Invoice>(`/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<AppSnapshot[]>({
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<K8sRevisionData>({
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<string | null>(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<typeof resourceForm>) => {
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<Invoice>(`/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 (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center gap-4">
<div className="skeleton w-14 h-14 rounded-2xl" />
<div className="space-y-2 flex-1">
<div className="skeleton h-6 w-48" />
<div className="skeleton h-4 w-72" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card space-y-3">
{[1,2,3,4,5].map(i => <div key={i} className="skeleton h-4 w-full" />)}
</div>
<div className="card space-y-3">
{[1,2,3].map(i => <div key={i} className="skeleton h-12 w-full rounded-lg" />)}
</div>
</div>
</div>
);
}
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 (
<>
<DeletingModal open={pageLocked} resourceName={app.name} resourceKind="application" />
<div
className={`space-y-6 animate-fade-in ${pageLocked ? 'pointer-events-none select-none opacity-50' : ''}`}
aria-hidden={pageLocked}
>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-2xl bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-7 h-7 ${app.runtime === 'nodejs' ? 'text-green-500' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 truncate">{app.name}</h1>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
{statusLabel(latestStatus)}
</span>
</div>
<p className="text-sm text-gray-500 truncate">
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '}
<a href={`https://${currentDomain}`} target="_blank" rel="noopener noreferrer" dir="ltr" className="text-primary-600 hover:underline">{currentDomain}</a>
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
{!hasDeployments && (
<button onClick={() => deployMutation.mutate()} disabled={deployMutation.isPending || (!app.codePath && !app.gitUrl)} className="btn-primary text-sm disabled:opacity-50">
{deployMutation.isPending ? <><Clock className="w-4 h-4 inline animate-spin" />{ad.deployingBtn}</> : <><Rocket className="w-4 h-4 inline" />{ad.deploy}</>}
</button>
)}
{hasDeployments && (
<>
{isStopped ? (
<button onClick={() => startMutation.mutate()} disabled={startMutation.isPending} className="btn-primary text-sm disabled:opacity-50">
{startMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Play className="w-3 h-3 inline" />{ad.start}</>}
</button>
) : isRunning ? (
<button onClick={() => stopMutation.mutate()} disabled={stopMutation.isPending || isInProgress} className="btn-secondary text-sm disabled:opacity-50">
{stopMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Square className="w-3 h-3 inline" />{ad.stop}</>}
</button>
) : null}
{isRunning && (
<button onClick={() => restartMutation.mutate()} disabled={restartMutation.isPending} className="btn-secondary text-sm disabled:opacity-50">
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" />{ad.restart}</>}
</button>
)}
{!isInProgress && hasPaidAccess && isApplicationProduct(app) && (
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title={ad.rebuildTitle}>
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" />{ad.redeploy}</>}
</button>
)}
{isRunning && (
<button onClick={() => previewMutation.mutate()} disabled={previewMutation.isPending} className="text-sm px-4 py-2 rounded-xl font-medium bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 disabled:opacity-50 transition-all active:scale-[0.98]">
{previewMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><Globe className="w-3 h-3 inline" />{ad.preview}</>}
</button>
)}
{app.enableElasticsearch && (
<NextLink
href={`/dashboard/logs?appId=${appId}`}
className="text-sm px-4 py-2 rounded-xl font-medium bg-slate-50 text-slate-700 hover:bg-slate-100 border border-slate-200 transition-all active:scale-[0.98] inline-flex items-center gap-1"
>
<ScrollText className="w-3 h-3" />{ad.logs}</NextLink>
)}
</>
)}
<button
onClick={handleDelete}
disabled={pageLocked}
className="btn-danger text-sm disabled:opacity-50"
>{t.common.delete}</button>
</div>
</div>
{/* Renewal Banner for Expired/Suspended Apps */}
{needsRenewal && (
<div className={`rounded-xl p-4 border-2 ${
app.lifecycleStatus === 'pending_deletion'
? 'bg-red-50 border-red-300'
: 'bg-amber-50 border-amber-300'
}`}>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-3 flex-1">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
app.lifecycleStatus === 'pending_deletion' ? 'bg-red-100' : 'bg-amber-100'
}`}>
<AlertTriangle className={`w-6 h-6 ${
app.lifecycleStatus === 'pending_deletion' ? 'text-red-600' : 'text-amber-600'
}`} />
</div>
<div>
<h3 className={`font-semibold ${
app.lifecycleStatus === 'pending_deletion' ? 'text-red-800' : 'text-amber-800'
}`}>
{app.lifecycleStatus === 'pending_deletion'
? ad.scheduledForDeletion
: ad.suspendedPayment}
</h3>
<p className={`text-sm ${
app.lifecycleStatus === 'pending_deletion' ? 'text-red-600' : 'text-amber-600'
}`}>
{app.lifecycleStatus === 'pending_deletion'
? ad.willBeDeletedOn.replace('{date}', app.scheduledDeletionAt ? new Date(app.scheduledDeletionAt).toLocaleString(locale) : ad.soon)
: ad.planExpiredRestore}
</p>
</div>
</div>
<button
onClick={() => setShowRenewalModal(true)}
className={`px-6 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
app.lifecycleStatus === 'pending_deletion'
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-amber-600 text-white hover:bg-amber-700'
}`}
>
<CreditCard className="w-4 h-4" />{ad.renewNow}</button>
</div>
</div>
)}
{/* Expiring Soon Warning */}
{!needsRenewal && isExpiringSoon && app.planExpiresAt && (
<div className="rounded-xl p-4 border bg-blue-50 border-blue-200">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<Clock className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="font-medium text-blue-800">{ad.planExpiringSoon}</h3>
<p className="text-sm text-blue-600">
{ad.planExpiresEarly.replace('{date}', new Date(app.planExpiresAt).toLocaleString(locale))}
</p>
</div>
</div>
<button
onClick={() => setShowRenewalModal(true)}
className="px-4 py-2 rounded-lg font-medium bg-blue-600 text-white hover:bg-blue-700 transition-all flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />{ad.extendPlan}</button>
</div>
</div>
)}
{/* Renewal Modal */}
{showRenewalModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-2">{ad.renewApplication}</h2>
<p className="text-sm text-gray-500 mb-6">{ad.selectBillingCycle.replace('{name}', app.name)}</p>
{/* Wallet Balance */}
<div className="bg-gray-50 rounded-xl p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Wallet className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-600">{ad.walletBalance}</span>
</div>
<span className="text-lg font-bold text-gray-900">
{walletData?.balance?.toLocaleString('en-US') || 0} {ad.toman}
</span>
</div>
{/* Billing Cycle Selection */}
<div className="space-y-3 mb-6">
{renewalCostData?.costs && (
<>
<label
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedCycle === 'hourly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => setSelectedCycle('hourly')}
>
<div className="flex items-center gap-3">
<input type="radio" checked={selectedCycle === 'hourly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
<div>
<p className="font-medium text-gray-900">{ad.cycles.hourly}</p>
<p className="text-xs text-gray-500">{ad.payAsYouGo}</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.hourly.toLocaleString('en-US')} {ad.toman}</span>
</label>
<label
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedCycle === 'monthly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => setSelectedCycle('monthly')}
>
<div className="flex items-center gap-3">
<input type="radio" checked={selectedCycle === 'monthly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
<div>
<p className="font-medium text-gray-900">{ad.cycles.monthly}</p>
<p className="text-xs text-gray-500">{ad.bestForMost}</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.monthly.toLocaleString('en-US')} {ad.toman}</span>
</label>
<label
className={`flex items-center justify-between p-4 border-2 rounded-xl cursor-pointer transition-all ${
selectedCycle === 'yearly' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => setSelectedCycle('yearly')}
>
<div className="flex items-center gap-3">
<input type="radio" checked={selectedCycle === 'yearly'} onChange={() => {}} className="w-4 h-4 text-primary-600" />
<div>
<p className="font-medium text-gray-900">{ad.cycles.yearly}</p>
<p className="text-xs text-green-600">{ad.saveUpTo20}</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.yearly.toLocaleString('en-US')} {ad.toman}</span>
</label>
</>
)}
</div>
{/* 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 (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
{ad.walletShortBy.replace('{n}', (cost - walletData.balance).toLocaleString('en-US'))}
</p>
</div>
);
}
return null;
})()
)}
{/* Coupon */}
<div className="mb-4">
<label className="text-sm font-medium text-gray-700">
{t.dashboard.billing.discounts.coupon.label}
</label>
<input
className="input-field w-full font-mono mt-1.5"
placeholder={t.dashboard.billing.discounts.coupon.placeholder}
value={renewCoupon}
onChange={(e) => setRenewCoupon(e.target.value.toUpperCase())}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => { setShowRenewalModal(false); setRenewCoupon(''); }}
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
>{t.common.cancel}</button>
<button
onClick={() => createRenewalInvoiceMutation.mutate(selectedCycle)}
disabled={createRenewalInvoiceMutation.isPending || !renewalCostData?.costs}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{createRenewalInvoiceMutation.isPending ? (
<><Clock className="w-4 h-4 animate-spin" />{ad.processing}</>
) : (
<><CreditCard className="w-4 h-4" />{ad.createInvoicePay}</>
)}
</button>
</div>
</div>
</div>
)}
{/* Upgrade Confirmation Modal */}
{showUpgradeConfirm && upgradeCostData && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-2">{ad.confirmResourceUpgrade}</h2>
<p className="text-sm text-gray-500 mb-6">
{upgradeCostData.proratedAmount > 0
? 'This upgrade requires payment for the remaining billing period.'
: 'No additional cost for this change.'}
</p>
{/* Cost Summary */}
<div className="bg-gray-50 rounded-xl p-4 mb-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">{ad.currentHourlyCost}</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.currentCost.hourly.toLocaleString('en-US')} {ad.tomanPerHour}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">{ad.newHourlyCost}</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.newCost.hourly.toLocaleString('en-US')} {ad.tomanPerHour}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">{ad.remainingHours}</span>
<span className="text-sm font-medium text-gray-900">
{ad.hoursUnit.replace('{n}', String(upgradeCostData.remainingHours))}
</span>
</div>
<div className="border-t pt-3 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">{ad.proratedAmount}</span>
<span className="text-lg font-bold text-primary-600">
{upgradeCostData.proratedAmount.toLocaleString('en-US')} {ad.toman}
</span>
</div>
</div>
{/* Wallet Balance */}
<div className="bg-blue-50 rounded-xl p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Wallet className="w-5 h-5 text-blue-500" />
<span className="text-sm text-blue-700">{ad.walletBalance}</span>
</div>
<span className="text-lg font-bold text-blue-900">
{walletData?.balance?.toLocaleString('en-US') || 0} {ad.toman}
</span>
</div>
{/* Insufficient Balance Warning */}
{walletData && upgradeCostData.proratedAmount > walletData.balance && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
{ad.walletShortBy.replace('{n}', (upgradeCostData.proratedAmount - walletData.balance).toLocaleString('en-US'))}
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => {
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
}}
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
>{t.common.cancel}</button>
<button
onClick={confirmUpgradeApply}
disabled={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{scaleMutation.isPending || createUpgradeInvoiceMutation.isPending ? (
<><Clock className="w-4 h-4 animate-spin" />{ad.applying}</>
) : upgradeCostData.proratedAmount > 0 ? (
<><CreditCard className="w-4 h-4" />{ad.createInvoicePay}</>
) : (
<><CheckCircle className="w-4 h-4" />{ad.applyChanges}</>
)}
</button>
</div>
</div>
</div>
)}
{/* Status & Config */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{ad.configuration}</h2>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.runtime}</dt>
<dd className="text-sm font-medium text-gray-900">
{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}` : ''}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.database}</dt>
<dd className="text-sm font-medium text-gray-900">
{app.databaseType}
{app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.replicas}</dt>
<dd className="text-sm font-medium text-gray-900">{app.replicas}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.cpu}</dt>
<dd className="text-sm font-medium text-gray-900">{app.cpuRequest} / {app.cpuLimit}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.memory}</dt>
<dd className="text-sm font-medium text-gray-900">{app.memoryRequest} / {app.memoryLimit}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.port}</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div>
{isAdmin && app.clusterId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.cluster}</dt>
<dd className="text-sm font-medium text-gray-900">
<Server className="w-4 h-4 inline text-gray-400" /> {clusters.find((c) => c.id === app.clusterId)?.name || 'Unknown'}
</dd>
</div>
)}
{isAdmin && app.poolId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.pool}</dt>
<dd className="text-sm font-medium text-gray-900">
<Scale className="w-4 h-4 inline text-gray-400" /> {pools.find((p) => p.id === app.poolId)?.name || 'Unknown'}
</dd>
</div>
)}
{app.latestImageTag && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">{ad.image}</dt>
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
{app.latestImageTag}
</dd>
</div>
)}
</dl>
</div>
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{ad.deploymentHistory}</h2>
{deployments.length === 0 ? (
<div className="text-center py-8">
<Package className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">{ad.noDeployments}</p>
<p className="text-gray-400 text-xs mt-1">{ad.uploadToStart}</p>
</div>
) : (
<div className="space-y-3 max-h-72 overflow-y-auto">
{deployments.slice(0, 10).map((d) => (
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50/80 rounded-xl">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">{d.version || d.imageTag}</p>
<p className="text-xs text-gray-500">
{new Date(d.createdAt).toLocaleString(locale)}
</p>
{d.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}>
<XCircle className="w-3 h-3 inline" /> {d.errorMessage}
</p>
)}
</div>
<span className={`badge ${statusColors[d.status] || 'badge-gray'} ml-2 shrink-0`}>
{statusLabel(d.status)}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Source Code Upload */}
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" />{ad.sourceCode}</h2>
{app.codePath ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center text-green-600">
<CheckCircle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-green-800">{ad.sourceCodeUploaded}</p>
<p className="text-xs text-green-600">{app.codePath.split('/').pop()}</p>
</div>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="text-sm text-green-700 hover:text-green-900 font-medium"
>{ad.replace}</button>
</div>
) : app.gitUrl ? (
<div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-xl mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
<Link className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-medium text-blue-800">{ad.gitConnected}</p>
<p className="text-xs text-blue-600 font-mono">{app.gitUrl}</p>
<div className="flex items-center space-x-3 mt-1">
{app.gitBranch && (
<span className="text-xs text-blue-500 flex items-center gap-1">
<GitBranch className="w-3 h-3" /> {app.gitBranch}
</span>
)}
{(app.hasGitToken ?? app.gitToken) && (
<span className="text-xs text-green-600 flex items-center gap-1">
<KeyRound className="w-3 h-3" />{ad.private}</span>
)}
</div>
</div>
</div>
</div>
) : null}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all
${isDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}
${uploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}
`}
>
<input
ref={fileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
e.target.value = '';
}}
/>
{uploadMutation.isPending ? (
<div className="space-y-3">
<Upload className="w-8 h-8 mx-auto text-gray-400 animate-pulse" />
<p className="text-sm font-medium text-gray-700">Uploading... {uploadProgress}%</p>
<div className="w-48 mx-auto bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<div className="space-y-2">
<FolderUp className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">
{app.codePath ? 'Upload new version' : 'Upload your project source code'}
</p>
<p className="text-xs text-gray-500">
{ad.dragDrop} <strong>.zip</strong> {ad.fileHereBrowse}
</p>
<p className="text-xs text-gray-400">{ad.maxSize10gb}</p>
</div>
)}
</div>
</div>
{/* Custom Domain */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Globe className="w-5 h-5" />{ad.domain}</h2>
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
<button
onClick={() => setShowDomainSetup(true)}
className="btn-primary text-sm"
>{ad.addCustomDomain}</button>
)}
</div>
{/* Application domain — the host the app is reachable on right now
(verified custom domain, else the platform subdomain). Click to copy. */}
<div className="bg-gray-50 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs text-gray-500 mb-1">{ad.appDomain}</p>
<button
type="button"
onClick={() => copyDomain(currentDomain)}
title={ad.copyDomainHint}
dir="ltr"
className="group inline-flex items-center gap-1.5 max-w-full text-sm font-mono font-medium text-gray-800 hover:text-primary-600 transition-colors"
>
<span className="truncate">{currentDomain}</span>
<Copy className="w-3.5 h-3.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
</div>
<span className="badge badge-green text-xs shrink-0">{ad.active}</span>
</div>
</div>
{/* Custom domain - verified */}
{app.customDomain && app.customDomainStatus === 'verified' && (
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-emerald-600 mb-1">{ad.customDomain}</p>
<button
type="button"
onClick={() => copyDomain(app.customDomain!)}
title={ad.copyDomainHint}
dir="ltr"
className="group inline-flex items-center gap-1.5 max-w-full text-sm font-mono font-medium text-emerald-800 hover:text-emerald-600 transition-colors"
>
<span className="truncate">{app.customDomain}</span>
<Copy className="w-3.5 h-3.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
</button>
<p className="text-xs text-emerald-500 mt-1">
<CheckCircle className="w-3 h-3 inline" /> SSL Active Verified on{' '}
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString(locale) : ''}
</p>
</div>
<button
onClick={async () => {
const ok = await confirm({
title: ad.removeDomainTitle,
message: ad.removeDomainConfirm.replace('{domain}', app.customDomain || ''),
confirmText: ad.remove,
variant: 'danger',
});
if (ok) removeDomainMutation.mutate();
}}
disabled={removeDomainMutation.isPending}
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
>
{removeDomainMutation.isPending ? 'Removing...' : 'Remove Domain'}
</button>
</div>
</div>
)}
{/* Custom domain - pending DNS */}
{app.customDomain && app.customDomainStatus === 'pending_dns' && (
<div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-xs text-amber-600 mb-1">{ad.customDomainPending}</p>
<p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => verifyDnsMutation.mutate()}
disabled={verifyDnsMutation.isPending}
className="btn-primary text-sm"
>
{verifyDnsMutation.isPending ? ad.checking : ad.verifyDns}
</button>
<button
onClick={() => removeDomainMutation.mutate()}
disabled={removeDomainMutation.isPending}
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
>{t.common.cancel}</button>
</div>
</div>
{/* DNS Instructions */}
<div className="bg-white rounded-lg p-4 border border-amber-100">
<h4 className="text-sm font-semibold text-gray-800 mb-3">{ad.dnsSetupGuide}</h4>
<div className="space-y-2.5 text-sm text-gray-600">
<p>{ad.dnsStep1}</p>
<p>{ad.dnsStep2}</p>
<p>{ad.dnsStep3a}<strong>CNAME</strong>{ad.dnsStep3b}</p>
<div className="pl-4 rtl:pl-0 rtl:pr-4 space-y-1">
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">{ad.nameHost}<strong>@</strong> {ad.orText} <strong>www</strong></p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">{ad.typeColon}<strong>CNAME</strong></p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">{ad.valueColon}<strong>{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.abrban.com`}</strong></p>
</div>
<p>{ad.dnsStep4a}<code className="bg-gray-100 px-1 rounded">www</code>{ad.dnsStep4b}</p>
<p>{ad.dnsStep5}</p>
<p>{ad.dnsStep6a}<strong>&quot;{ad.verifyDns}&quot;</strong>{ad.dnsStep6b}</p>
</div>
{domainInfo?.fullPlatformUrl && (
<div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100">
<p className="text-xs text-blue-700 font-medium mb-1">{ad.cnameTarget}</p>
<div className="flex items-center gap-2">
<code className="text-sm font-mono text-blue-900 bg-blue-100 px-2 py-1 rounded flex-1">
{domainInfo.fullPlatformUrl}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
notify.success(ad.copied);
}}
className="text-blue-600 hover:text-blue-800 p-1"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Domain setup form */}
{showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
<h4 className="text-sm font-semibold text-gray-800 mb-3">{ad.setUpCustomDomain}</h4>
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100">
<p className="text-sm text-blue-700">
<CreditCard className="w-4 h-4 inline mr-1" />{ad.customDomainFee}<strong>{domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman / month</strong>
</p>
<p className="text-xs text-blue-500 mt-1">{ad.feeIncludedNote}</p>
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={customDomainInput}
onChange={(e) => setCustomDomainInput(e.target.value)}
placeholder={ad.domainPlaceholder}
className="input-field flex-1 font-mono text-sm"
/>
<button
onClick={() => {
if (customDomainInput.trim()) setDomainMutation.mutate(customDomainInput.trim());
}}
disabled={!customDomainInput.trim() || setDomainMutation.isPending}
className="btn-primary text-sm disabled:opacity-50"
>
{setDomainMutation.isPending ? 'Setting up...' : 'Set Domain'}
</button>
<button
onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }}
className="btn-secondary text-sm"
>{t.common.cancel}</button>
</div>
</div>
)}
</div>
{/* Database Info & Dump Upload */}
{app.databaseType !== 'none' && (
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Database className="w-5 h-5" />{ad.database}<span className="badge badge-blue text-xs">{app.databaseType}</span>
</h2>
{/* Connection Info */}
<div className="bg-gray-50 rounded-xl p-4 mb-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-700 mb-3">{ad.connectionInfoInternal}</h3>
{[
{ 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 }) => (
<div key={field} className="flex items-center justify-between">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-gray-800">{value}</span>
<button
onClick={() => copyToClipboard(value, field)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={ad.copy}
>
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
{/* Password row with show/hide */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">{ad.password}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-gray-800">
{showDbPassword ? (app.dbPassword || '—') : '••••••••••••'}
</span>
<button
onClick={() => setShowDbPassword(!showDbPassword)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={showDbPassword ? 'Hide' : 'Show'}
>
{showDbPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => copyToClipboard(app.dbPassword || '', 'password')}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={ad.copy}
>
{copiedField === 'password' ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<p className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">{ad.dbOnlyInternal}</p>
</div>
{/* DB Dump Upload */}
<h3 className="text-sm font-semibold text-gray-700 mb-3">{ad.restoreDatabaseDump}</h3>
<div
onDrop={handleDbDrop}
onDragOver={handleDbDragOver}
onDragLeave={handleDbDragLeave}
onClick={() => 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' : ''}
`}
>
<input
ref={dbFileInputRef}
type="file"
accept=".sql,.gz,.dump"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleDbFileUpload(file);
e.target.value = '';
}}
/>
{dbUploadMutation.isPending ? (
<div className="space-y-2">
<Database className="w-8 h-8 mx-auto text-blue-400 animate-pulse" />
<p className="text-sm font-medium text-gray-700">{ad.restoringDatabase}</p>
<p className="text-xs text-gray-500">{ad.thisMayTake}</p>
</div>
) : (
<div className="space-y-2">
<Database className="w-8 h-8 mx-auto text-gray-400" />
<p className="text-sm font-medium text-gray-700">{ad.uploadSqlToRestore}</p>
<p className="text-xs text-gray-500">
{ad.dragDrop} <strong>.sql</strong> {ad.fileHereBrowse}
</p>
<p className="text-xs text-gray-400">{ad.maxSize500mb}</p>
</div>
)}
</div>
{/* Restore Logs */}
{dbRestoreLogs && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-600 mb-2">{ad.restoreOutput}</h4>
<pre className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words">
{dbRestoreLogs}
</pre>
</div>
)}
</div>
)}
{/* Optional Service Credentials */}
{(app.enableRedis || app.enableRabbitmq) && (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<KeyRound className="w-5 h-5" />{ad.serviceCredentials}</h2>
<button
type="button"
onClick={() => setShowServiceSecrets(!showServiceSecrets)}
className="btn-secondary text-xs inline-flex items-center gap-1"
>
{showServiceSecrets ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
{showServiceSecrets ? 'Hide secrets' : 'Show secrets'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{app.enableRedis && (
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-700">{ad.workload}</h3>
{[
{ 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 }) => (
<div key={field} className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
</span>
<button
type="button"
onClick={() => copyToClipboard(value || '', field)}
disabled={!value}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
title={ad.copy}
>
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
)}
{app.enableRabbitmq && (
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-700">{ad.workload}</h3>
{[
{ 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 }) => (
<div key={field} className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
</span>
<button
type="button"
onClick={() => copyToClipboard(value || '', field)}
disabled={!value}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
title={ad.copy}
>
{copiedField === field ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
)}
</div>
{!app.latestImageTag && (
<p className="text-xs text-gray-400 mt-3">{ad.serviceCredsAfterDeploy}</p>
)}
</div>
)}
{app && <ServiceExternalAccessPanel appId={appId} app={app} />}
{/* Resource Monitoring & Scaling */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><BarChart3 className="w-5 h-5" />{ad.resourcesScaling}</h2>
<button
onClick={() => setShowResources(!showResources)}
className="btn-secondary text-sm"
>
{showResources ? <><ChevronDown className="w-4 h-4 inline" />{ad.hide}</> : <><BarChart3 className="w-4 h-4 inline" />{ad.monitor}</>}
</button>
</div>
{showResources && (
<div className="space-y-6">
{/* Live Metrics */}
{resourcesLoading ? (
<div className="text-center py-6 text-gray-400 text-sm">{ad.loadingMetrics}</div>
) : resourceUsage ? (
<>
{resourceUsage.loggingNote && (
<p className="text-xs text-gray-600 bg-slate-50 border border-slate-100 rounded-lg px-3 py-2">{resourceUsage.loggingNote}</p>
)}
{(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) => (
<div key={w.key} className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">{w.title}</h3>
<span className="text-[11px] text-gray-400 font-mono truncate max-w-[200px]" title={w.deploymentName}>{w.deploymentName}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-[10px] text-blue-600 font-medium">{ad.replicas}</p>
<p className="text-lg font-bold text-blue-800">
{w.configured.readyReplicas}/{w.configured.replicas}
</p>
<p className="text-[10px] text-blue-500">{ad.ready}</p>
</div>
<div className="bg-green-50 rounded-lg p-3">
<p className="text-[10px] text-green-600 font-medium">{ad.pods}</p>
<p className="text-lg font-bold text-green-800">{w.pods.length}</p>
<p className="text-[10px] text-green-500">{w.pods.filter((p) => p.ready).length} {ad.ready}</p>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<p className="text-[10px] text-purple-600 font-medium">{ad.metrics}</p>
<p className="text-lg font-bold text-purple-800 flex justify-center">
{w.metrics.length > 0 ? <CheckCircle className="w-5 h-5 text-purple-700" /> : <Clock className="w-5 h-5 text-purple-400" />}
</p>
<p className="text-[10px] text-purple-500">{w.metrics.length > 0 ? 'Live' : 'Waiting…'}</p>
</div>
</div>
<div className="text-xs text-gray-600 grid sm:grid-cols-2 gap-2 border-t border-gray-200 pt-3">
<div>
<span className="text-gray-400">{ad.cpuColon}</span>
<span className="font-mono">{w.configured.cpuRequest} {w.configured.cpuLimit}</span>
</div>
<div>
<span className="text-gray-400">{ad.memoryColon}</span>
<span className="font-mono">{w.configured.memoryRequest} {w.configured.memoryLimit}</span>
</div>
</div>
{w.metrics.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-semibold text-gray-600">{ad.liveUsage}</h4>
{w.metrics.map((metric) => {
const cpuUsed = parseCpuToMillicores(metric.cpu);
const cpuLimit = parseCpuToMillicores(w.configured.cpuLimit);
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
const memUsed = parseMemoryToMi(metric.memory);
const memLimit = parseMemoryToMi(w.configured.memoryLimit);
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
return (
<div key={metric.name} className="bg-white rounded-lg p-3 space-y-2 border border-gray-100">
<p className="text-[11px] font-mono text-gray-600 truncate" title={metric.name}>
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
</p>
<div>
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
<span>{ad.cpu}</span>
<span>{cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className={`h-2 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${cpuPercent}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
<span>{ad.memory}</span>
<span>{memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className={`h-2 rounded-full ${memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${memPercent}%` }} />
</div>
</div>
</div>
);
})}
</div>
)}
{w.pods.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-600 mb-1">{ad.pods}</h4>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-start text-gray-500 border-b border-gray-200">
<th className="pb-1 font-medium text-start">{ad.name}</th>
<th className="pb-1 font-medium text-start">{ad.status}</th>
<th className="pb-1 font-medium text-start">{ad.ready}</th>
<th className="pb-1 font-medium text-start">{ad.restarts}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{w.pods.map((pod) => (
<tr key={pod.name} className="text-gray-700">
<td className="py-1 text-start font-mono truncate max-w-[140px]" dir="ltr" title={pod.name}>{pod.name}</td>
<td className="py-1 text-start">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
</td>
<td className="py-1 text-start">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
<td className="py-1 text-start">{pod.restarts}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{w.sidecars && w.sidecars.length > 0 && (
<div className="text-[11px] text-gray-600 border-t border-dashed border-gray-200 pt-2">
<span className="font-semibold text-gray-700">{ad.sidecars}</span>
{w.sidecars.map((s) => (
<span key={s.name} className="mr-3">
{s.name} <span className="text-gray-400">(CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'})</span>
</span>
))}
</div>
)}
</div>
))}
{/* Storage Usage Section */}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Database className="w-4 h-4" />{ad.storageUsage}</h3>
{storageUsageLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">{ad.loadingStorageMetrics}</div>
) : storageUsage ? (
<div className="space-y-4">
{/* Database Storage */}
{storageUsage.database && (
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600">{ad.databaseVolume}</span>
<span className="text-xs text-gray-500">
{storageUsage.database.usedGi.toFixed(2)} GiB / {storageUsage.database.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-500 ${
storageUsage.database.usedPercent > 80
? 'bg-red-500'
: storageUsage.database.usedPercent > 50
? 'bg-yellow-500'
: 'bg-blue-500'
}`}
style={{ width: `${Math.min(storageUsage.database.usedPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Used {storageUsage.database.usedGi.toFixed(2)} GiB</span>
<span>Free ~{storageUsage.database.availableGi.toFixed(2)} GiB</span>
</div>
<p className="text-[11px] text-gray-400 mt-2">{ad.expandDiskNote}</p>
</div>
)}
{storageUsage.appStorage && (
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-600">
{app?.runtime === 'wordpress' ? 'wp-content volume' : 'Application volume'}
</span>
<span className="text-xs text-gray-500">
{storageUsage.appStorage.usedGi.toFixed(2)} GiB / {storageUsage.appStorage.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-500 ${
storageUsage.appStorage.usedPercent > 80
? 'bg-red-500'
: storageUsage.appStorage.usedPercent > 50
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{ width: `${Math.min(storageUsage.appStorage.usedPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Used {storageUsage.appStorage.usedGi.toFixed(2)} GiB</span>
<span>Free ~{storageUsage.appStorage.availableGi.toFixed(2)} GiB</span>
</div>
<p className="text-[11px] text-gray-400 mt-2">{ad.expandDiskNote}</p>
</div>
)}
{storageUsage.redisStorage && app?.enableRedis && (
<div className="bg-amber-50/80 rounded-xl p-4 border border-amber-100">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-700">{ad.redisVolume}</span>
<span className="text-xs text-gray-500">
{storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${storageUsage.redisStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-amber-500'}`}
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
/>
</div>
<p className="text-[11px] text-gray-500 mt-1">
Allocated {storageUsage.redisStorage.allocatedRaw} expand in Adjust CPU / memory &amp; storage.
</p>
</div>
)}
{storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
<div className="bg-violet-50/80 rounded-xl p-4 border border-violet-100">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-700">{ad.rabbitmqVolume}</span>
<span className="text-xs text-gray-500">
{storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${storageUsage.rabbitmqStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-violet-500'}`}
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
/>
</div>
<p className="text-[11px] text-gray-500 mt-1">
Allocated {storageUsage.rabbitmqStorage.allocatedRaw} expand in Adjust CPU / memory &amp; storage.
</p>
</div>
)}
{!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (
<p className="text-sm text-gray-400 text-center py-4">{ad.noStorageData}</p>
)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4">{ad.storageMetricsUnavailable}</p>
)}
</div>
{/* Scaling Controls */}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Settings className="w-4 h-4" />{ad.adjustCpuMemStorage}</h3>
<p className="text-xs text-gray-500 mb-3">
{ad.pickComponentNote}
</p>
<div className="mb-4">
<label className="block text-xs text-gray-500 mb-1">{ad.workload}</label>
<Select
size="md"
className="max-w-md"
ariaLabel={ad.workload}
value={scaleWorkload}
onChange={(v) => setScaleWorkload(v as typeof scaleWorkload)}
options={[
{ value: 'app', label: ad.productTypeApp },
...(app?.databaseType !== 'none' ? [{ value: 'database', label: ad.database }] : []),
...(app?.enableRedis ? [{ value: 'redis', label: ad.workload }] : []),
...(app?.enableRabbitmq ? [{ value: 'rabbitmq', label: ad.workload }] : []),
]}
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">{ad.cpuRequest}</label>
<input
type="text"
value={resourceForm.cpuRequest}
onChange={(e) => patchResourceForm({ cpuRequest: e.target.value })}
className="input-field text-sm"
placeholder="100m"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{ad.cpuLimit}</label>
<input
type="text"
value={resourceForm.cpuLimit}
onChange={(e) => patchResourceForm({ cpuLimit: e.target.value })}
className="input-field text-sm"
placeholder="500m"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{ad.memoryRequest}</label>
<input
type="text"
value={resourceForm.memoryRequest}
onChange={(e) => patchResourceForm({ memoryRequest: e.target.value })}
className="input-field text-sm"
placeholder="128Mi"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">{ad.memoryLimit}</label>
<input
type="text"
value={resourceForm.memoryLimit}
onChange={(e) => patchResourceForm({ memoryLimit: e.target.value })}
className="input-field text-sm"
placeholder="512Mi"
/>
</div>
{scaleWorkload === 'app' && (
<div>
<label className="block text-xs text-gray-500 mb-1">{ad.replicas}</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => patchResourceForm({ replicas: Math.max(1, resourceForm.replicas - 1) })}
className="btn-icon w-9 h-9"
>
</button>
<span className="text-lg font-bold text-gray-800 w-8 text-center">{resourceForm.replicas}</span>
<button
type="button"
onClick={() => patchResourceForm({ replicas: Math.min(10, resourceForm.replicas + 1) })}
className="btn-icon w-9 h-9"
>
+
</button>
</div>
</div>
)}
</div>
{(() => {
const storageCfg = workloadStorageConfig();
if (!storageCfg) return null;
return (
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
<label className="block text-xs font-medium text-gray-600 mb-2">
Storage {storageCfg.label}
</label>
<p className="text-[11px] text-gray-500 mb-2">
Current: {storageCfg.currentGi.toFixed(1)} GiB allocated (expand only, no shrink). Applied with
Apply changes.
</p>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const c = parseInt(storageCfg.value, 10);
if (c > storageCfg.minGi + 1) storageCfg.setValue(String(c - 1));
}}
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
>
</button>
<input
type="number"
min={storageCfg.minGi + 1}
max={storageCfg.maxGi}
value={storageCfg.value}
onChange={(e) => {
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"
/>
<button
type="button"
onClick={() => {
const c = parseInt(storageCfg.value, 10);
if (c < storageCfg.maxGi) storageCfg.setValue(String(c + 1));
}}
className="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
>
+
</button>
</div>
<span className="text-xs text-gray-600">GiB</span>
</div>
</div>
);
})()}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div className="sm:col-span-2" />
<div className="flex items-end">
<button
type="button"
onClick={handleScaleResources}
disabled={
scaleMutation.isPending ||
calculateUpgradeCostMutation.isPending ||
directPatchResourcesMutation.isPending
}
className="btn-primary text-sm w-full disabled:opacity-50"
>
{directPatchResourcesMutation.isPending ? (
<><Clock className="w-3 h-3 inline animate-spin" />{ad.applying}</>
) : scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? (
<><Clock className="w-3 h-3 inline animate-spin" />{ad.calculating}</>
) : (
<><RefreshCw className="w-3 h-3 inline" />{ad.applyChanges}</>
)}
</button>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-6">
<p className="text-gray-400 text-sm">
{isStopped ? 'Application is stopped. Start it to see resources.' : 'No resource data available yet.'}
</p>
</div>
)}
</div>
)}
</div>
{/* Snapshots & Rollback */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><History className="w-5 h-5" />{ad.rollbackSnapshots}</h2>
<div className="flex items-center gap-2">
<button
onClick={() => createSnapshotMutation.mutate()}
disabled={createSnapshotMutation.isPending}
className="btn-secondary text-sm disabled:opacity-50"
>
{createSnapshotMutation.isPending
? <><Clock className="w-3 h-3 inline animate-spin" />{ad.creating}</>
: <><Camera className="w-3 h-3 inline" />{ad.newSnapshot}</>}
</button>
<button
onClick={() => setShowSnapshots(!showSnapshots)}
className="btn-secondary text-sm"
>
{showSnapshots ? <><ChevronDown className="w-4 h-4 inline" />{ad.hide}</> : <><History className="w-4 h-4 inline" />{ad.show}</>}
</button>
</div>
</div>
{showSnapshots && (
<div className="space-y-4">
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl">
<button
onClick={() => setSnapshotTab('revisions')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
snapshotTab === 'revisions' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Zap className="w-4 h-4" />{ad.k8sRevisions}<span className="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">{ad.instant}</span>
</button>
<button
onClick={() => setSnapshotTab('snapshots')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
snapshotTab === 'snapshots' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Camera className="w-4 h-4" />{ad.fileSnapshots}<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">{ad.full}</span>
</button>
</div>
{/* ─── K8s Revisions Tab ─── */}
{snapshotTab === 'revisions' && (
<div className="space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3">
<p className="text-xs text-amber-700">
<Zap className="w-3 h-3 inline" /> <strong>{ad.instantRollback}</strong> {ad.revisionsHelmNote}
</p>
</div>
{revisionsLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">{ad.loadingRevisions}</div>
) : !revisionData?.revisions?.length ? (
<div className="text-center py-8">
<History className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">{ad.noRevisions}</p>
<p className="text-gray-400 text-xs mt-1">{ad.revisionsAfterDeploy}</p>
</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{revisionData.revisions.map((rev) => (
<div key={rev.revision} className={`border rounded-xl p-4 transition-all ${
rev.isCurrent ? 'border-green-300 bg-green-50 ring-1 ring-green-200' : 'border-gray-200 bg-white hover:border-gray-300'
}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-900">{ad.revision} {rev.revision}</span>
{rev.isCurrent && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" />{ad.current}</span>
)}
</div>
<p className="text-xs text-gray-500 mt-1 truncate" title={rev.changeCause}>{rev.changeCause}</p>
<p className="text-xs text-gray-400 mt-0.5">{new Date(rev.createdAt).toLocaleString(locale)}</p>
</div>
{!rev.isCurrent && (
<button
onClick={() => handleRevisionRollback(rev)}
disabled={revisionRollbackMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 transition-colors disabled:opacity-50"
title={ad.instantRollbackTitle}
>
<RotateCcw className="w-3.5 h-3.5" />{ad.rollback}</button>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ─── File Snapshots Tab ─── */}
{snapshotTab === 'snapshots' && (
<div className="space-y-4">
{/* Download current live state */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-blue-800 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" />{ad.downloadCurrentState}</h3>
<p className="text-xs text-blue-600 mb-3">{ad.downloadCurrentNote}</p>
<div className="flex flex-wrap gap-2">
{app.codePath && (
<button
onClick={() => downloadCurrentArtifact('source')}
disabled={!!downloadingArtifact}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
{downloadingArtifact === 'source' ? (
<><RefreshCw className="w-3 h-3 animate-spin" />{ad.downloading2}</>
) : (
<><Archive className="w-3 h-3" />{ad.sourceCode}</>
)}
</button>
)}
{app.runtime?.toLowerCase() === 'wordpress' && (
<button
onClick={() => downloadCurrentArtifact('wp-content')}
disabled={!!downloadingArtifact}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
{downloadingArtifact === 'wp-content' ? (
<><RefreshCw className="w-3 h-3 animate-spin" />{ad.downloading2}</>
) : (
<><Archive className="w-3 h-3" /> wp-content</>
)}
</button>
)}
{app.databaseType && app.databaseType.toLowerCase() !== 'none' && (
<button
onClick={() => downloadCurrentArtifact('database')}
disabled={!!downloadingArtifact}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
{downloadingArtifact === 'database' ? (
<><RefreshCw className="w-3 h-3 animate-spin" />{ad.downloading2}</>
) : (
<><Database className="w-3 h-3" />{ad.databaseDump}</>
)}
</button>
)}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-xl p-3">
<p className="text-xs text-gray-500">
<Camera className="w-3 h-3 inline" /> <strong>{ad.fullSnapshots}</strong> {ad.snapshotsIncludeNote}
</p>
</div>
{/* Snapshot list */}
{snapshotsLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">{ad.loadingSnapshots}</div>
) : snapshots.length === 0 ? (
<div className="text-center py-8">
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">{ad.noSnapshots}</p>
<p className="text-gray-400 text-xs mt-1">{ad.snapshotsAutoNote}</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{snapshots.map((snap) => (
<div key={snap.id} className={`border rounded-xl p-4 transition-all ${
snap.status === 'completed' ? 'border-gray-200 bg-white' :
snap.status === 'in_progress' ? 'border-blue-200 bg-blue-50' :
'border-red-200 bg-red-50'
}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Untitled'}</p>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.status === 'completed' ? 'bg-green-100 text-green-700' :
snap.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>
{snap.status === 'in_progress' ? 'Creating...' : snap.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
{new Date(snap.createdAt).toLocaleString(locale)}
{snap.imageTag && <span className="ml-2 font-mono text-gray-400">image: {snap.imageTag.split(':').pop()?.slice(0, 12)}</span>}
</p>
{/* Artifact sizes */}
{snap.status === 'completed' && (
<div className="flex flex-wrap gap-3 mt-2">
{snap.appArchivePath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Package className="w-3 h-3" /> Source: {formatBytes(snap.appArchiveSize)}
</span>
)}
{snap.wpContentArchivePath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Archive className="w-3 h-3" /> wp-content: {formatBytes(snap.wpContentSize)}
</span>
)}
{snap.dbDumpPath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Database className="w-3 h-3" /> DB: {formatBytes(snap.dbDumpSize)}
</span>
)}
</div>
)}
{snap.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={snap.errorMessage}>
<XCircle className="w-3 h-3 inline" /> {snap.errorMessage}
</p>
)}
</div>
{/* Actions */}
{snap.status === 'completed' && (
<div className="flex items-center gap-1 shrink-0">
{snap.appArchivePath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'source')}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title={ad.downloadSource}
>
<Download className="w-4 h-4" />
</button>
)}
{snap.wpContentArchivePath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'wp-content')}
className="p-1.5 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
title={ad.downloadWpContent}
>
<Archive className="w-4 h-4" />
</button>
)}
{snap.dbDumpPath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'database')}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title={ad.downloadDatabaseDump}
>
<Database className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleRollback(snap)}
disabled={rollbackMutation.isPending}
className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors disabled:opacity-50"
title={ad.rollbackSnapTitleBtn}
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteSnapshot(snap)}
disabled={deletingSnapshotId !== null}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title={ad.deleteSnapshot}
>
{deletingSnapshotId === snap.id ? (
<Clock className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 text-center">{ad.maxSnapshots}</p>
</div>
)}
</div>
)}
</div>
<WorkloadLogsPanel
appId={appId}
showBuildLogs={isApplicationProduct(app)}
isRunning={isRunning}
isStopped={isStopped}
/>
</div>
</>
);
}