22359be40e
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>
2767 lines
130 KiB
TypeScript
2767 lines
130 KiB
TypeScript
'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>"{ad.verifyDns}"</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 & 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 & 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>
|
||
</>
|
||
);
|
||
}
|