97e4c865b6
Co-authored-by: Cursor <cursoragent@cursor.com>
2848 lines
140 KiB
TypeScript
2848 lines
140 KiB
TypeScript
'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 5–30 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>
|
||
);
|
||
}
|