Files
cloud-host/frontend/src/app/dashboard/apps/[id]/page.tsx
T
2026-05-18 22:37:13 +03:30

2848 lines
140 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, Invoice } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
import NextLink from 'next/link';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { BuildProgressModal } from '@/components/build-progress-modal';
/** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
const statusColors: Record<string, string> = {
running: 'badge-green',
pending: 'badge-yellow',
building: 'badge-blue',
deploying: 'badge-blue',
failed: 'badge-red',
build_failed: 'badge-red',
stopped: 'badge-gray',
cancelled: 'badge-gray',
};
/**
* Parse CPU value to millicores (e.g. "100m" → 100, "1" → 1000, "250n" → 0.00025)
*/
function parseCpuToMillicores(cpu: string): number {
if (!cpu || cpu === '0') return 0;
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;
if (cpu.endsWith('u')) return parseFloat(cpu) / 1_000;
if (cpu.endsWith('m')) return parseFloat(cpu);
return parseFloat(cpu) * 1000;
}
/**
* Parse memory value to MiB (e.g. "128Mi" → 128, "1Gi" → 1024, "131072Ki" → 128)
*/
function parseMemoryToMi(memory: string): number {
if (!memory || memory === '0') return 0;
if (memory.endsWith('Ki')) return parseFloat(memory) / 1024;
if (memory.endsWith('Mi')) return parseFloat(memory);
if (memory.endsWith('Gi')) return parseFloat(memory) * 1024;
if (memory.endsWith('Ti')) return parseFloat(memory) * 1024 * 1024;
// raw bytes
return parseFloat(memory) / (1024 * 1024);
}
export default function AppDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const confirm = useConfirm();
const appId = params.id as string;
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const fileInputRef = useRef<HTMLInputElement>(null);
const logsEndRef = useRef<HTMLPreElement>(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 [showDbDiskExpand, setShowDbDiskExpand] = useState(false);
const [dbStorageSize, setDbStorageSize] = useState('1');
const [dbStorageLoading, setDbStorageLoading] = useState(false);
const [showSnapshots, setShowSnapshots] = useState(false);
const [downloadingArtifact, setDownloadingArtifact] = useState<'source' | 'wp-content' | 'database' | null>(null);
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
const [showRenewalModal, setShowRenewalModal] = useState(false);
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
const [upgradeCostData, setUpgradeCostData] = useState<{
proratedAmount: number;
remainingHours: number;
currentCost: { hourly: number };
newCost: { hourly: number };
} | null>(null);
// ── Custom Domain ──────────────────────────────────
const [showDomainSetup, setShowDomainSetup] = useState(false);
const [customDomainInput, setCustomDomainInput] = useState('');
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60);
const [showAccessSecret, setShowAccessSecret] = useState(false);
const [accessNow, setAccessNow] = useState(() => Date.now());
const { data: app, isLoading } = useQuery<Application>({
queryKey: ['application', appId],
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
});
useEffect(() => {
if (!app) return;
if (scaleWorkload === 'database' && app.databaseType === 'none') setScaleWorkload('app');
else if (scaleWorkload === 'redis' && !app.enableRedis) setScaleWorkload('app');
else if (scaleWorkload === 'rabbitmq' && !app.enableRabbitmq) setScaleWorkload('app');
}, [app, scaleWorkload]);
const { data: deployments = [] } = useQuery<Deployment[]>({
queryKey: ['deployments', appId],
queryFn: () => api.get(`/deployments/applications/${appId}`).then((r) => r.data),
refetchInterval: 5000, // Poll for status updates
});
const { data: logsData } = useQuery<{ logs: string }>({
queryKey: ['logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
enabled: showLogs && logTab === 'pod',
refetchInterval: showLogs && logTab === 'pod' ? 3000 : false,
});
const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({
queryKey: ['build-logs', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data),
enabled: showLogs && logTab === 'build',
refetchInterval: showLogs && logTab === 'build' ? 5000 : false,
});
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<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),
});
const { data: pools = [] } = useQuery<ClusterPoolPublic[]>({
queryKey: ['pools-public'],
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
});
// Fetch DB storage size
const { data: dbStorageData } = useQuery<{ currentSize: string; savedSize: string }>({
queryKey: ['db-storage', appId],
queryFn: () => api.get(`/applications/${appId}/db-storage`).then((r) => r.data),
enabled: !!app && app.databaseType !== 'none',
});
// Sync dbStorageSize state when data loads
useEffect(() => {
if (dbStorageData?.currentSize) {
const sizeNum = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1;
setDbStorageSize(String(sizeNum));
}
}, [dbStorageData]);
// Fetch comprehensive storage usage (allocated/used/available)
interface StorageUsageSlice {
allocatedRaw: string;
allocatedGi: number;
usedGi: number;
availableGi: number;
usedPercent: number;
}
interface StorageUsageData {
database: StorageUsageSlice | null;
appStorage: StorageUsageSlice | null;
redisStorage?: StorageUsageSlice | null;
rabbitmqStorage?: StorageUsageSlice | null;
totalAllocatedGb?: number;
totalUsedGb?: number;
configured?: { dbStorageSize: string; appStorageSize: string };
}
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<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');
const [showAppStorageExpand, setShowAppStorageExpand] = useState(false);
useEffect(() => {
if (app?.appStorageSize) {
const sizeNum = parseInt(app.appStorageSize.replace('Gi', ''), 10) || 2;
setAppStorageSize(String(sizeNum));
}
}, [app?.appStorageSize]);
const resizeAppStorageMutation = useMutation({
mutationFn: (size: string) => api.patch(`/applications/${appId}/app-storage`, { size }),
onSuccess: (res) => {
if (res.data.success) {
toast.success(res.data.message || 'App storage expanded!');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
setShowAppStorageExpand(false);
} else {
toast.error(res.data.message || 'Failed to expand storage');
}
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to resize app storage');
},
});
const resizeDbMutation = useMutation({
mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }),
onSuccess: (res) => {
if (res.data.success) {
toast.success(res.data.message || 'Database storage expanded!');
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', appId] });
} else {
toast.error(res.data.message || 'Failed to expand storage');
}
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to resize database storage');
},
});
// ─── Billing & Renewal ──────────────────────────────
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: renewalCostData } = useQuery<{
costs: { hourly: number; monthly: number; yearly: number; currentCycle?: string };
}>({
queryKey: ['renewal-cost', appId],
queryFn: () => api.get(`/billing/applications/${appId}/renewal-cost`).then((r) => r.data),
enabled: showRenewalModal || (app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion'),
});
const renewMutation = useMutation({
mutationFn: (cycle: string) => api.post(`/billing/applications/${appId}/renew`, { cycle }),
onSuccess: (res) => {
toast.success(res.data.message || 'Application renewed successfully!');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
setShowRenewalModal(false);
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to renew application');
},
});
const createRenewalInvoiceMutation = useMutation({
mutationFn: (cycle: string) =>
api.post<Invoice>(`/billing/applications/${appId}/renew/invoice`, { cycle }).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowRenewalModal(false);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to create renewal invoice');
},
});
// Check if app needs renewal (expired or suspended)
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
// ─── Custom Domain ──────────────────────────────────
const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{
customDomain: string | null;
customDomainStatus: string;
platformDomain: string;
fullPlatformUrl: string;
cnameTarget: string;
instructions: string[];
}>({
queryKey: ['domain-info', appId],
queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data),
enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')),
});
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
queryKey: ['custom-domain-price'],
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
});
const setDomainMutation = useMutation({
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
onSuccess: () => {
toast.success('Domain set. Please configure your DNS records.');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo();
setCustomDomainInput('');
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to set domain'),
});
const verifyDnsMutation = useMutation({
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
onSuccess: (res) => {
if (res.data.verified) {
toast.success('Domain verified successfully!');
} else {
toast.warning(res.data.message || 'DNS is not ready yet. Please try again later.');
}
queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo();
},
onError: (err: any) => toast.error(err.response?.data?.message || 'DNS verification failed'),
});
const removeDomainMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}/domain`),
onSuccess: () => {
toast.success('Custom domain removed');
queryClient.invalidateQueries({ queryKey: ['application', appId] });
refetchDomainInfo();
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to remove domain'),
});
// ─── Snapshots ──────────────────────────────────────
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<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) => {
toast.success(res.data.message || 'Rollback completed');
queryClient.invalidateQueries({ queryKey: ['revisions', appId] });
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Rollback failed'),
});
const handleRevisionRollback = async (rev: K8sRevision) => {
const ok = await confirm({
title: `Rollback to Revision ${rev.revision}?`,
message: `This will rollback the Helm release to revision ${rev.revision}.\n${rev.changeCause ? `\nDescription: ${rev.changeCause}` : ''}\n\nNo rebuild needed — takes effect in seconds.`,
confirmText: 'Rollback',
variant: 'warning',
});
if (ok) revisionRollbackMutation.mutate(rev.revision);
};
const createSnapshotMutation = useMutation({
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
toast.success('Snapshot creation started');
},
onError: () => toast.error('Failed to create snapshot'),
});
const rollbackMutation = useMutation({
mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`),
onSuccess: (res) => {
const details = res.data.details || [];
toast.success('Rollback completed:\n' + details.join('\n'));
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
},
onError: () => toast.error('Rollback failed'),
});
const deleteSnapshotMutation = useMutation({
mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
toast.success('Snapshot deleted');
},
onError: () => toast.error('Failed to delete snapshot'),
});
const handleRollback = async (snap: AppSnapshot) => {
const ok = await confirm({
title: `Rollback to "${snap.label}"?`,
message: 'This will restore:\n• App deployment via K8s revision (instant)\n• wp-content files (WordPress)\n• Database dump\n\nThe current state will be overwritten.',
confirmText: 'Rollback',
variant: 'warning',
});
if (ok) rollbackMutation.mutate(snap.id);
};
const handleDeleteSnapshot = async (snap: AppSnapshot) => {
const ok = await confirm({
title: 'Delete snapshot?',
message: `Delete "${snap.label}"? The archived files will be permanently removed.`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteSnapshotMutation.mutate(snap.id);
};
const downloadSnapshotArtifact = (snapshotId: string, artifact: 'source' | 'wp-content' | 'database') => {
const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/${artifact}`;
const token = localStorage.getItem('accessToken');
const a = document.createElement('a');
a.href = url;
// Use fetch for auth download
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => r.blob())
.then((blob) => {
const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql';
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${artifact}-${snapshotId.slice(0, 8)}${ext}`;
link.click();
URL.revokeObjectURL(link.href);
})
.catch(() => toast.error(`Failed to download ${artifact}`));
};
const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => {
// Prevent duplicate downloads
if (downloadingArtifact) {
toast.warn('A download is already in progress. Please wait.');
return;
}
setDownloadingArtifact(artifact);
const url = `${api.defaults.baseURL}/snapshots/applications/${appId}/current/${artifact}`;
const token = localStorage.getItem('accessToken');
const timeout = 15 * 60 * 1000; // 15 minutes
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const artifactName = artifact === 'source' ? 'Source Code' : artifact === 'wp-content' ? 'wp-content' : 'Database';
toast.info(`Downloading ${artifactName}... This may take up to 15 minutes for large files.`);
fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
})
.then((r) => {
clearTimeout(timeoutId);
if (!r.ok) throw new Error('Not found');
return r.blob();
})
.then((blob) => {
const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql';
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `current-${artifact}${ext}`;
link.click();
URL.revokeObjectURL(link.href);
toast.success(`${artifactName} downloaded successfully!`);
})
.catch((err) => {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
toast.error(`Download timed out after 15 minutes. Try again or check server logs.`);
} else {
toast.error(`Failed to download current ${artifact}`);
}
})
.finally(() => {
setDownloadingArtifact(null);
});
};
const formatBytes = (bytes?: number) => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const patchResourceForm = (patch: Partial<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
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight;
}
}, [logsData]);
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
queryClient.invalidateQueries({ queryKey: ['application', appId] });
};
const deployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
onSuccess: () => {
invalidateAll();
toast.success('Deployment triggered!');
},
onError: () => toast.error('Failed to trigger deployment'),
});
const stopMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/stop`),
onSuccess: () => {
invalidateAll();
toast.success('Application stopped');
},
onError: () => toast.error('Failed to stop application'),
});
const startMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/start`),
onSuccess: () => {
invalidateAll();
toast.success('Application started');
},
onError: () => toast.error('Failed to start application'),
});
const restartMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/restart`),
onSuccess: () => {
invalidateAll();
toast.success('Application restarting...');
},
onError: () => toast.error('Failed to restart application'),
});
const redeployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
onSuccess: () => {
invalidateAll();
toast.success('Redeploy triggered — building new version from latest source');
},
onError: () => toast.error('Failed to trigger redeploy'),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/applications/${appId}`),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
if (res.data?.resourceCredit) {
toast.success('Application deleted. Prepaid resources are on your dashboard.');
} else {
toast.success('Application deleted');
}
router.push('/dashboard/apps');
},
onError: () => toast.error('Failed to delete application'),
});
const scaleMutation = useMutation({
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
api.post(`/billing/applications/${appId}/upgrade`, data),
onSuccess: (res) => {
setResourceFormDirty(false);
invalidateAll();
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
const paidAmount = res.data.paidAmount || 0;
if (paidAmount > 0) {
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
} else {
toast.success('Resources updated successfully!');
}
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
});
/** CPU/memory for DB / Redis / RabbitMQ — applies directly in Kubernetes (no billing wizard). */
const directPatchResourcesMutation = useMutation({
mutationFn: (data: {
workload: 'database' | 'redis' | 'rabbitmq';
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
}) => api.patch(`/applications/${appId}/resources`, data),
onSuccess: () => {
setResourceFormDirty(false);
invalidateAll();
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
toast.success('Resources updated');
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update resources'),
});
// Calculate upgrade cost before applying
const calculateUpgradeCostMutation = useMutation({
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
api.post(`/billing/applications/${appId}/upgrade/calculate`, data),
onSuccess: (res) => {
setUpgradeCostData(res.data);
setShowUpgradeConfirm(true);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to calculate upgrade cost'),
});
const createUpgradeInvoiceMutation = useMutation({
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
api.post<Invoice>(`/billing/applications/${appId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create upgrade invoice'),
});
// Handler: app uses billing upgrade path when subscribed; other workloads patch directly.
const handleScaleResources = () => {
if (scaleWorkload !== 'app') {
directPatchResourcesMutation.mutate({
workload: scaleWorkload,
cpuRequest: resourceForm.cpuRequest || undefined,
cpuLimit: resourceForm.cpuLimit || undefined,
memoryRequest: resourceForm.memoryRequest || undefined,
memoryLimit: resourceForm.memoryLimit || undefined,
});
return;
}
if (!app?.billingCycle) {
scaleMutation.mutate(resourceForm);
return;
}
calculateUpgradeCostMutation.mutate(resourceForm);
};
const previewMutation = useMutation({
mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data),
onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => {
// Open the preview URL in a new tab
window.open(data.url, '_blank');
toast.success(`Preview opened on port ${data.nodePort}`);
},
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
});
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
if (app?.databaseType && app.databaseType !== 'none') {
accessTargetOptions.push({ value: 'database', label: 'Database' });
}
if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' });
if (app?.enableRabbitmq) {
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
}
const hasAccessTargets = accessTargetOptions.length > 0;
useEffect(() => {
if (!hasAccessTargets) return;
if (!accessTargetOptions.some((o) => o.value === accessTarget)) {
setAccessTarget(accessTargetOptions[0].value);
}
}, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]);
const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery<ServiceAccessGrant[]>({
queryKey: ['access-grants', appId],
queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data),
enabled: hasAccessTargets && !!app?.latestImageTag,
refetchInterval: 30000,
});
useEffect(() => {
if (!accessGrants.some((g) => g.status === 'active')) return;
const t = setInterval(() => setAccessNow(Date.now()), 1000);
return () => clearInterval(t);
}, [accessGrants]);
const createAccessMutation = useMutation({
mutationFn: () =>
api.post(`/applications/${appId}/access`, {
target: accessTarget,
durationMinutes: accessDuration,
}).then((r) => r.data),
onSuccess: () => {
refetchAccessGrants();
toast.success('Temporary external access enabled');
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to enable access');
},
});
const revokeAccessMutation = useMutation({
mutationFn: (grantId: string) =>
api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data),
onSuccess: () => {
refetchAccessGrants();
toast.success('Access revoked');
},
onError: () => toast.error('Failed to revoke access'),
});
const accessTargetLabel = (target: ServiceAccessTarget) =>
accessTargetOptions.find((o) => o.value === target)?.label || target;
const formatAccessCountdown = (expiresAt: string) => {
const ms = new Date(expiresAt).getTime() - accessNow;
if (ms <= 0) return 'Expired';
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
};
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/applications/${appId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded * 100) / e.total));
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', appId] });
toast.success('Source code uploaded successfully!');
setUploadProgress(0);
},
onError: () => {
toast.error('Failed to upload source code');
setUploadProgress(0);
},
});
const dbUploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/applications/${appId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: (res) => {
const data = res.data;
setDbRestoreLogs(data.logs || null);
if (data.success) {
toast.success('Database restored successfully!');
} else {
toast.error(data.message || 'Database restore failed');
}
},
onError: (err: any) => {
toast.error(err.response?.data?.message || 'Failed to upload database dump');
setDbRestoreLogs(null);
},
});
const handleFileUpload = useCallback((file: File) => {
if (!file.name.endsWith('.zip') && !file.name.endsWith('.tar.gz') && !file.name.endsWith('.tgz')) {
toast.error('Please upload a .zip or .tar.gz file');
return;
}
if (file.size > MAX_SOURCE_ARCHIVE_BYTES) {
toast.error('File size must be at most 10 GB');
return;
}
uploadMutation.mutate(file);
}, [uploadMutation]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
}, [handleFileUpload]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const handleDbFileUpload = useCallback((file: File) => {
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
toast.error('Please upload a .sql, .dump, or .gz file');
return;
}
if (file.size > 500 * 1024 * 1024) {
toast.error('File size must be less than 500MB');
return;
}
setDbRestoreLogs(null);
dbUploadMutation.mutate(file);
}, [dbUploadMutation]);
const handleDbDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDraggingDb(false);
const file = e.dataTransfer.files[0];
if (file) handleDbFileUpload(file);
}, [handleDbFileUpload]);
const handleDbDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDraggingDb(true);
}, []);
const handleDbDragLeave = useCallback(() => {
setIsDraggingDb(false);
}, []);
const copyToClipboard = useCallback((text: string, field: string) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
}, []);
if (isLoading || !app) {
return (
<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>
);
}
const latestStatus = deployments[0]?.status || 'pending';
const hasDeployments = deployments.length > 0;
const isStopped = latestStatus === 'stopped';
const isRunning = latestStatus === 'running';
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
const hasPaidAccess = !app.billingCycle ||
(app.lifecycleStatus === 'active' && (!app.planExpiresAt || new Date(app.planExpiresAt) > new Date()));
const handleDelete = async () => {
const ok = await confirm({
title: `Delete "${app.name}"?`,
message:
'This will permanently remove all Kubernetes resources, data, and deployment records.\n\n' +
(app.planExpiresAt && new Date(app.planExpiresAt) > new Date()
? 'Your remaining paid resources will appear on the dashboard for use on a new app at no extra charge.'
: ''),
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteMutation.mutate();
};
return (
<div className="space-y-6 animate-fade-in">
{/* 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'}`}>
{latestStatus}
</span>
</div>
<p className="text-sm text-gray-500 truncate">
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '}
{app.customDomain && app.customDomainStatus === 'verified'
? <a href={`https://${app.customDomain}`} target="_blank" rel="noopener noreferrer" className="text-emerald-600 hover:underline">{app.customDomain}</a>
: <span>{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}</span>
}
</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" /> Deploying...</> : <><Rocket className="w-4 h-4 inline" /> 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" /> 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" /> 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" /> Restart</>}
</button>
)}
{!isInProgress && hasPaidAccess && (
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> 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" /> 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" /> Logs
</NextLink>
)}
</>
)}
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : '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'
? 'Application Scheduled for Deletion!'
: 'Application Suspended — Payment Required'}
</h3>
<p className={`text-sm ${
app.lifecycleStatus === 'pending_deletion' ? 'text-red-600' : 'text-amber-600'
}`}>
{app.lifecycleStatus === 'pending_deletion'
? `This application will be permanently deleted on ${app.scheduledDeletionAt ? new Date(app.scheduledDeletionAt).toLocaleString() : 'soon'}. Renew now to prevent data loss.`
: 'Your plan has expired. Renew to restore service access.'}
</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" />
Renew Now
</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">Plan Expiring Soon</h3>
<p className="text-sm text-blue-600">
Your plan expires on {new Date(app.planExpiresAt).toLocaleString()}. Renew early to avoid service interruption.
</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" />
Extend Plan
</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">Renew Application</h2>
<p className="text-sm text-gray-500 mb-6">Select a billing cycle to renew "{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">Wallet Balance</span>
</div>
<span className="text-lg font-bold text-gray-900">
{walletData?.balance?.toLocaleString() || 0} 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">Hourly</p>
<p className="text-xs text-gray-500">Pay as you go, auto-renews</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.hourly.toLocaleString()} 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">Monthly</p>
<p className="text-xs text-gray-500">Best for most users</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.monthly.toLocaleString()} 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">Yearly</p>
<p className="text-xs text-green-600">Save up to 20%</p>
</div>
</div>
<span className="font-bold text-gray-900">{renewalCostData.costs.yearly.toLocaleString()} 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" />
Wallet is short by {(cost - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
</p>
</div>
);
}
return null;
})()
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => setShowRenewalModal(false)}
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"
>
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" /> Processing...</>
) : (
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
)}
</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">Confirm Resource Upgrade</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">Current hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">New hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Remaining hours in period</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.remainingHours} hours
</span>
</div>
<div className="border-t pt-3 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Prorated amount to pay</span>
<span className="text-lg font-bold text-primary-600">
{upgradeCostData.proratedAmount.toLocaleString()} 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">Wallet Balance</span>
</div>
<span className="text-lg font-bold text-blue-900">
{walletData?.balance?.toLocaleString() || 0} 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" />
Wallet is short by {(upgradeCostData.proratedAmount - walletData.balance).toLocaleString()} Toman. You can pay the delta by gateway on the invoice page.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => {
setShowUpgradeConfirm(false);
setUpgradeCostData(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"
>
Cancel
</button>
<button
onClick={() => {
if (upgradeCostData.proratedAmount > 0) {
createUpgradeInvoiceMutation.mutate(resourceForm);
} else {
scaleMutation.mutate(resourceForm);
}
}}
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" /> Applying...</>
) : upgradeCostData.proratedAmount > 0 ? (
<><CreditCard className="w-4 h-4" /> Create Invoice & Pay</>
) : (
<><CheckCircle className="w-4 h-4" /> Apply Changes</>
)}
</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">Configuration</h2>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">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">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">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">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">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">Port</dt>
<dd className="text-sm font-medium text-gray-900">{app.port}</dd>
</div>
{app.clusterId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">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>
)}
{app.poolId && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">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">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">Deployment History</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">No deployments yet</p>
<p className="text-gray-400 text-xs mt-1">Upload source code and click Deploy to get started</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()}
</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`}>
{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" /> Source Code</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">Source code uploaded</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"
>
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">Git repository connected</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.gitToken && (
<span className="text-xs text-green-600 flex items-center gap-1">
<KeyRound className="w-3 h-3" /> 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">
Drag & drop a <strong>.zip</strong> file here, or click to browse
</p>
<p className="text-xs text-gray-400">Max size: 10 GB</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" /> Domain
</h2>
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
<button
onClick={() => setShowDomainSetup(true)}
className="btn-primary text-sm"
>
Add Custom Domain
</button>
)}
</div>
{/* Platform domain (always shown) */}
<div className="bg-gray-50 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 mb-1">Platform Domain</p>
<p className="text-sm font-mono font-medium text-gray-800">
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}
</p>
</div>
<span className="badge badge-green text-xs">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">Custom Domain</p>
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p>
<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() : ''}
</p>
</div>
<button
onClick={async () => {
const ok = await confirm({
title: 'Remove Custom Domain',
message: `Are you sure you want to remove "${app.customDomain}"? The website will only be accessible via the platform domain.`,
confirmText: '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">Custom Domain Pending DNS Verification</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 ? 'Checking...' : 'Verify DNS'}
</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"
>
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">DNS Setup Guide</h4>
<div className="space-y-2.5 text-sm text-gray-600">
<p>1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)</p>
<p>2. Go to DNS management for your domain</p>
<p>3. Add a <strong>CNAME</strong> record:</p>
<div className="pl-4 space-y-1">
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Name/Host: <strong>@</strong> or <strong>www</strong></p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Type: <strong>CNAME</strong></p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">Value: <strong>{domainInfo?.fullPlatformUrl || `${app.subdomain}.apps.cloudhost.ir`}</strong></p>
</div>
<p>4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare), or use <code className="bg-gray-100 px-1 rounded">www</code> instead.</p>
<p>5. Wait 530 minutes for DNS propagation (up to 48 hours in some cases)</p>
<p>6. Click the <strong>"Verify DNS"</strong> button above</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">CNAME Target:</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);
toast.success('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">Set Up Custom Domain</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" />
Custom domain fee: <strong>{domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman / month</strong>
</p>
<p className="text-xs text-blue-500 mt-1">
This fee is included in the total cost calculation.
</p>
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={customDomainInput}
onChange={(e) => setCustomDomainInput(e.target.value)}
placeholder="example.com or www.example.com"
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"
>
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" /> 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">Connection Info (Internal Cluster)</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="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">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="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">
Database is only accessible within the cluster. Not exposed externally.
</p>
</div>
{/* Database Storage Management */}
<div className="bg-gray-50 rounded-xl p-4 mb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Database Storage</h3>
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500">Current Size:</span>
<span className="text-sm font-semibold text-gray-800">{dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => {
const current = parseInt(dbStorageSize, 10);
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
if (current > min + 1) setDbStorageSize(String(current - 1));
}}
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={dbStorageSize}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
setDbStorageSize(String(val));
}}
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(dbStorageSize, 10);
if (current < 100) setDbStorageSize(String(current + 1));
}}
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
>
+
</button>
</div>
<span className="text-sm text-gray-600">GB</span>
<button
type="button"
onClick={() => {
const newSize = `${parseInt(dbStorageSize, 10)}Gi`;
resizeDbMutation.mutate(newSize);
}}
disabled={
resizeDbMutation.isPending ||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
}
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
>
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
</button>
</div>
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed (shrinking is not possible)</p>
</div>
</div>
</div>
{/* DB Dump Upload */}
<h3 className="text-sm font-semibold text-gray-700 mb-3">Restore Database Dump</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">Restoring database...</p>
<p className="text-xs text-gray-500">This may take a few minutes</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">Upload SQL dump to restore</p>
<p className="text-xs text-gray-500">
Drag & drop a <strong>.sql</strong> file here, or click to browse
</p>
<p className="text-xs text-gray-400">Max size: 500MB</p>
</div>
)}
</div>
{/* Restore Logs */}
{dbRestoreLogs && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-600 mb-2">Restore Output</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>
)}
{/* Temporary External Access */}
{hasAccessTargets && (
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
<ExternalLink className="w-5 h-5" /> Temporary External Access
</h2>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.
</p>
{!app?.latestImageTag ? (
<p className="text-sm text-gray-500">Deploy the application first to enable external access.</p>
) : (
<>
<div className="bg-gray-50 rounded-xl p-4 mb-4 flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
<select
value={accessTarget}
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
className="input w-full text-sm"
>
{accessTargetOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
<div className="flex gap-2">
{[30, 60, 240].map((mins) => (
<button
key={mins}
type="button"
onClick={() => setAccessDuration(mins)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
accessDuration === mins
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => createAccessMutation.mutate()}
disabled={createAccessMutation.isPending}
className="btn-primary text-sm"
>
{createAccessMutation.isPending ? 'Opening…' : 'Enable access'}
</button>
</div>
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
<p className="text-sm text-gray-500">No active external access sessions.</p>
) : (
<div className="space-y-3">
{accessGrants
.filter((g) => g.status === 'active')
.map((grant) => (
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
<span className="ml-2 text-xs text-gray-500">ends in {formatAccessCountdown(grant.expiresAt)}</span>
</div>
<button
type="button"
onClick={() => revokeAccessMutation.mutate(grant.id)}
disabled={revokeAccessMutation.isPending}
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
>
Revoke
</button>
</div>
<div className="space-y-1.5 text-sm font-mono">
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
<div className="flex items-center gap-2">
<span className="text-gray-800">{grant.host}:{grant.port}</span>
<button
type="button"
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
className="p-1 text-gray-400 hover:text-gray-600"
title="Copy"
>
{copiedField === `access-endpoint-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
{grant.connection.url && (
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">URL</span>
<div className="flex items-center gap-2 max-w-[70%]">
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
{showAccessSecret ? grant.connection.url : '••••••••••••'}
</span>
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button
type="button"
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
className="p-1 text-gray-400 hover:text-gray-600"
>
{copiedField === `access-url-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)}
{/* 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" /> Resources & Scaling</h2>
<button
onClick={() => setShowResources(!showResources)}
className="btn-secondary text-sm"
>
{showResources ? <><ChevronDown className="w-4 h-4 inline" /> Hide</> : <><BarChart3 className="w-4 h-4 inline" /> Monitor</>}
</button>
</div>
{showResources && (
<div className="space-y-6">
{/* Live Metrics */}
{resourcesLoading ? (
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics...</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: 'Application',
deploymentName: app?.name || '',
configured: resourceUsage.configured,
pods: resourceUsage.pods,
metrics: resourceUsage.metrics,
sidecars: undefined,
},
]
: []
).map((w) => (
<div key={w.key} className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">{w.title}</h3>
<span className="text-[11px] text-gray-400 font-mono truncate max-w-[200px]" title={w.deploymentName}>{w.deploymentName}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
<p className="text-lg font-bold text-blue-800">
{w.configured.readyReplicas}/{w.configured.replicas}
</p>
<p className="text-[10px] text-blue-500">ready</p>
</div>
<div className="bg-green-50 rounded-lg p-3">
<p className="text-[10px] text-green-600 font-medium">Pods</p>
<p className="text-lg font-bold text-green-800">{w.pods.length}</p>
<p className="text-[10px] text-green-500">{w.pods.filter((p) => p.ready).length} ready</p>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
<p className="text-lg font-bold text-purple-800 flex justify-center">
{w.metrics.length > 0 ? <CheckCircle className="w-5 h-5 text-purple-700" /> : <Clock className="w-5 h-5 text-purple-400" />}
</p>
<p className="text-[10px] text-purple-500">{w.metrics.length > 0 ? 'Live' : 'Waiting…'}</p>
</div>
</div>
<div className="text-xs text-gray-600 grid sm:grid-cols-2 gap-2 border-t border-gray-200 pt-3">
<div>
<span className="text-gray-400">CPU: </span>
<span className="font-mono">{w.configured.cpuRequest} {w.configured.cpuLimit}</span>
</div>
<div>
<span className="text-gray-400">Memory: </span>
<span className="font-mono">{w.configured.memoryRequest} {w.configured.memoryLimit}</span>
</div>
</div>
{w.metrics.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-semibold text-gray-600">Live usage</h4>
{w.metrics.map((metric) => {
const cpuUsed = parseCpuToMillicores(metric.cpu);
const cpuLimit = parseCpuToMillicores(w.configured.cpuLimit);
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
const memUsed = parseMemoryToMi(metric.memory);
const memLimit = parseMemoryToMi(w.configured.memoryLimit);
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
return (
<div key={metric.name} className="bg-white rounded-lg p-3 space-y-2 border border-gray-100">
<p className="text-[11px] font-mono text-gray-600 truncate" title={metric.name}>
<Circle className="w-2 h-2 inline fill-green-500 text-green-500" /> {metric.name}
</p>
<div>
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
<span>CPU</span>
<span>{cpuUsed.toFixed(1)}m / {cpuLimit.toFixed(0)}m ({cpuPercent.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className={`h-2 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : cpuPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${cpuPercent}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[11px] text-gray-500 mb-0.5">
<span>Memory</span>
<span>{memUsed.toFixed(1)}Mi / {memLimit.toFixed(0)}Mi ({memPercent.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className={`h-2 rounded-full ${memPercent > 80 ? 'bg-red-500' : memPercent > 50 ? 'bg-yellow-500' : 'bg-green-500'}`} style={{ width: `${memPercent}%` }} />
</div>
</div>
</div>
);
})}
</div>
)}
{w.pods.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-600 mb-1">Pods</h4>
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200">
<th className="pb-1 font-medium">Name</th>
<th className="pb-1 font-medium">Status</th>
<th className="pb-1 font-medium">Ready</th>
<th className="pb-1 font-medium">R</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{w.pods.map((pod) => (
<tr key={pod.name} className="text-gray-700">
<td className="py-1 font-mono truncate max-w-[140px]" title={pod.name}>{pod.name}</td>
<td className="py-1">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${pod.status === 'Running' ? 'bg-green-100 text-green-700' : pod.status === 'Pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>{pod.status}</span>
</td>
<td className="py-1">{pod.ready ? <CheckCircle className="w-3.5 h-3.5 text-green-500" /> : <Clock className="w-3.5 h-3.5 text-yellow-500" />}</td>
<td className="py-1">{pod.restarts}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{w.sidecars && w.sidecars.length > 0 && (
<div className="text-[11px] text-gray-600 border-t border-dashed border-gray-200 pt-2">
<span className="font-semibold text-gray-700">Sidecars: </span>
{w.sidecars.map((s) => (
<span key={s.name} className="mr-3">
{s.name} <span className="text-gray-400">(CPU {s.cpuLimit || '—'}, mem {s.memoryLimit || '—'})</span>
</span>
))}
</div>
)}
</div>
))}
{/* Storage Usage Section */}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Database className="w-4 h-4" /> Storage Usage
</h3>
{storageUsageLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">Loading storage metrics...</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">Database volume</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>
{app?.databaseType !== 'none' && (
<div className="mt-3 pt-3 border-t border-gray-200">
{showDbDiskExpand ? (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => {
const current = parseInt(dbStorageSize, 10);
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
if (current > min + 1) setDbStorageSize(String(current - 1));
}}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
>
</button>
<input
type="number"
min={1}
max={500}
value={dbStorageSize}
onChange={(e) => {
const val = Math.max(1, Math.min(500, parseInt(e.target.value, 10) || 1));
setDbStorageSize(String(val));
}}
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(dbStorageSize, 10);
if (current < 500) setDbStorageSize(String(current + 1));
}}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs"
>
+
</button>
</div>
<span className="text-xs text-gray-600">GiB</span>
<button
type="button"
onClick={() => resizeDbMutation.mutate(`${parseInt(dbStorageSize, 10)}Gi`)}
disabled={
resizeDbMutation.isPending ||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
}
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
>
{resizeDbMutation.isPending ? 'Expanding…' : 'Expand DB disk'}
</button>
<button type="button" onClick={() => setShowDbDiskExpand(false)} className="btn-secondary text-xs px-2 py-1">Cancel</button>
</div>
) : (
<button
type="button"
onClick={() => setShowDbDiskExpand(true)}
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
<Scale className="w-3 h-3" /> Expand database disk
</button>
)}
<p className="text-[11px] text-gray-400 mt-1">PVC can only grow. Size from API: {storageUsage.database.allocatedRaw}</p>
</div>
)}
</div>
)}
{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>
{/* Expand App Storage (all app types) */}
<div className="mt-3 pt-3 border-t border-gray-200">
{showAppStorageExpand ? (
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => {
const current = parseInt(appStorageSize, 10);
const min = parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2;
if (current > min + 1) setAppStorageSize(String(current - 1));
}}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
>
</button>
<input
type="number"
min={2}
max={100}
value={appStorageSize}
onChange={(e) => {
const val = Math.max(2, Math.min(100, parseInt(e.target.value, 10) || 2));
setAppStorageSize(String(val));
}}
className="w-12 text-center py-1 border-x border-gray-300 text-xs font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(appStorageSize, 10);
if (current < 100) setAppStorageSize(String(current + 1));
}}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-xs transition-colors"
>
+
</button>
</div>
<span className="text-xs text-gray-600">GB</span>
<button
type="button"
onClick={() => {
const newSize = `${parseInt(appStorageSize, 10)}Gi`;
resizeAppStorageMutation.mutate(newSize);
}}
disabled={
resizeAppStorageMutation.isPending ||
parseInt(appStorageSize, 10) <= (parseInt((app?.appStorageSize || '2Gi').replace('Gi', ''), 10) || 2)
}
className="btn-primary text-xs px-2 py-1 disabled:opacity-50"
>
{resizeAppStorageMutation.isPending ? 'Expanding...' : 'Expand'}
</button>
<button
type="button"
onClick={() => setShowAppStorageExpand(false)}
className="btn-secondary text-xs px-2 py-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setShowAppStorageExpand(true)}
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
>
<Scale className="w-3 h-3" /> Expand Storage
</button>
)}
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed</p>
</div>
</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">Redis (optional) volume</span>
<span className="text-xs text-gray-500">
{storageUsage.redisStorage.usedGi.toFixed(2)} GiB / {storageUsage.redisStorage.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${storageUsage.redisStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-amber-500'}`}
style={{ width: `${Math.min(storageUsage.redisStorage.usedPercent, 100)}%` }}
/>
</div>
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.redisStorage.allocatedRaw}</p>
</div>
)}
{storageUsage.rabbitmqStorage && app?.enableRabbitmq && (
<div className="bg-violet-50/80 rounded-xl p-4 border border-violet-100">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-700">RabbitMQ (optional) volume</span>
<span className="text-xs text-gray-500">
{storageUsage.rabbitmqStorage.usedGi.toFixed(2)} GiB / {storageUsage.rabbitmqStorage.allocatedGi.toFixed(1)} GiB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${storageUsage.rabbitmqStorage.usedPercent > 80 ? 'bg-red-500' : 'bg-violet-500'}`}
style={{ width: `${Math.min(storageUsage.rabbitmqStorage.usedPercent, 100)}%` }}
/>
</div>
<p className="text-[11px] text-gray-500 mt-1">Allocated {storageUsage.rabbitmqStorage.allocatedRaw}</p>
</div>
)}
{!storageUsage.database && !storageUsage.appStorage && !storageUsage.redisStorage && !storageUsage.rabbitmqStorage && (
<p className="text-sm text-gray-400 text-center py-4">No storage data available</p>
)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4">Storage metrics unavailable</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" /> Adjust CPU / memory</h3>
<p className="text-xs text-gray-500 mb-3">
Pick which component to update. The main application may use billing if your plan charges for upgrades; database and optional services apply directly in the cluster.
</p>
<div className="mb-4">
<label className="block text-xs text-gray-500 mb-1">Workload</label>
<select
value={scaleWorkload}
onChange={(e) => setScaleWorkload(e.target.value as typeof scaleWorkload)}
className="input-field text-sm max-w-md"
>
<option value="app">Application</option>
{app?.databaseType !== 'none' && <option value="database">Database</option>}
{app?.enableRedis && <option value="redis">Redis</option>}
{app?.enableRabbitmq && <option value="rabbitmq">RabbitMQ</option>}
</select>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CPU Request</label>
<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">CPU Limit</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">Memory Request</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">Memory Limit</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">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 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" /> Applying</>
) : scaleMutation.isPending || calculateUpgradeCostMutation.isPending ? (
<><Clock className="w-3 h-3 inline animate-spin" /> Calculating</>
) : (
<><RefreshCw className="w-3 h-3 inline" /> Apply changes</>
)}
</button>
</div>
</div>
</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" /> Rollback & Snapshots</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" /> Creating...</>
: <><Camera className="w-3 h-3 inline" /> New Snapshot</>}
</button>
<button
onClick={() => setShowSnapshots(!showSnapshots)}
className="btn-secondary text-sm"
>
{showSnapshots ? <><ChevronDown className="w-4 h-4 inline" /> Hide</> : <><History className="w-4 h-4 inline" /> 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" /> K8s Revisions
<span className="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">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" /> File Snapshots
<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">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>Instant rollback</strong> using Helm release revisions. Switches to a previous configuration in seconds no rebuild needed. Up to 10 revisions are kept.
</p>
</div>
{revisionsLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading revisions...</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">No revisions available</p>
<p className="text-gray-400 text-xs mt-1">Revisions appear after the first deployment.</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">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" /> 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()}</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="Instant rollback to this revision"
>
<RotateCcw className="w-3.5 h-3.5" />
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" /> Download Current State
</h3>
<p className="text-xs text-blue-600 mb-3">Download a copy of the current live files without creating a snapshot. Large files may take up to 15 minutes.</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" /> Downloading...</>
) : (
<><Archive className="w-3 h-3" /> Source Code</>
)}
</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" /> Downloading...</>
) : (
<><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" /> Downloading...</>
) : (
<><Database className="w-3 h-3" /> Database Dump</>
)}
</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>Full snapshots</strong> include source code, database dump, and wp-content. Use these to restore data or download backups. Auto-created before each deploy.
</p>
</div>
{/* Snapshot list */}
{snapshotsLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading snapshots...</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">No snapshots yet</p>
<p className="text-gray-400 text-xs mt-1">Snapshots are created automatically before each deploy, or you can create one manually.</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()}
{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="Download source code"
>
<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="Download wp-content"
>
<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="Download database dump"
>
<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="Rollback to this snapshot (uses K8s revision + restores DB/wp-content)"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteSnapshot(snap)}
disabled={deleteSnapshotMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title="Delete snapshot"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 text-center">Maximum 10 snapshots are kept. Older snapshots are automatically deleted.</p>
</div>
)}
</div>
)}
</div>
{/* Logs — Pod & Build */}
<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"><FileText className="w-5 h-5" /> Logs</h2>
<div className="flex items-center space-x-3">
{showLogs && logTab === 'pod' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>Live (every 3s)</span>
</span>
)}
{showLogs && logTab === 'build' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>Auto-refresh (every 5s)</span>
</span>
)}
<button
onClick={() => setShowLogs(!showLogs)}
className="btn-secondary text-sm"
>
{showLogs ? <><ChevronDown className="w-4 h-4 inline" /> Hide Logs</> : <><FileText className="w-4 h-4 inline" /> Show Logs</>}
</button>
</div>
</div>
{showLogs && (
<div className="space-y-3">
{/* Tab switcher */}
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
<button
onClick={() => setLogTab('pod')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
logTab === 'pod'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Monitor className="w-4 h-4 inline" /> Pod Logs
</button>
<button
onClick={() => setLogTab('build')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
logTab === 'build'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Hammer className="w-4 h-4 inline" /> Build Logs
</button>
</div>
{/* Pod logs */}
{logTab === 'pod' && (
<pre
ref={logsEndRef}
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
>
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
</pre>
)}
{/* Build logs */}
{logTab === 'build' && (
<div>
{buildLogsData?.version && (
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
<span><Pin className="w-3 h-3 inline" /> {buildLogsData.version}</span>
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
{buildLogsData.status}
</span>
</div>
)}
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
{buildLogsData?.buildLog || (
buildLogsData?.status === 'building'
? 'Build in progress... Logs will appear when complete.'
: buildLogsData?.status === 'pending'
? 'Build is pending...'
: buildLogsData?.status === 'no_deployment'
? 'No deployments yet. Deploy your app to see build logs.'
: 'No build logs available for this deployment.'
)}
</pre>
</div>
)}
</div>
)}
</div>
<BuildProgressModal appId={appId} enabled={isInProgress} />
</div>
);
}