add optinal apps
This commit is contained in:
@@ -6,7 +6,7 @@ import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
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 } from 'lucide-react';
|
||||
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 } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -71,7 +71,17 @@ export default function AppDetailPage() {
|
||||
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);
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
@@ -130,6 +140,46 @@ export default function AppDetailPage() {
|
||||
}
|
||||
}, [dbStorageData]);
|
||||
|
||||
// Fetch comprehensive storage usage (allocated/used/available)
|
||||
interface StorageUsageData {
|
||||
database: { allocated: number; used: number; available: number } | null;
|
||||
appStorage: { allocated: number; used: number; available: number } | null;
|
||||
}
|
||||
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) => {
|
||||
@@ -145,6 +195,37 @@ export default function AppDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// ─── Snapshots ──────────────────────────────────────
|
||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||
queryKey: ['snapshots', appId],
|
||||
@@ -251,10 +332,28 @@ export default function AppDetailPage() {
|
||||
};
|
||||
|
||||
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');
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
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();
|
||||
})
|
||||
@@ -265,8 +364,19 @@ export default function AppDetailPage() {
|
||||
link.download = `current-${artifact}${ext}`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
toast.success(`${artifactName} downloaded successfully!`);
|
||||
})
|
||||
.catch(() => toast.error(`Failed to download current ${artifact}`));
|
||||
.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) => {
|
||||
@@ -358,15 +468,45 @@ export default function AppDetailPage() {
|
||||
|
||||
const scaleMutation = useMutation({
|
||||
mutationFn: (data: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number }) =>
|
||||
api.patch(`/applications/${appId}/resources`, data),
|
||||
onSuccess: () => {
|
||||
api.post(`/billing/applications/${appId}/upgrade`, data),
|
||||
onSuccess: (res) => {
|
||||
invalidateAll();
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', appId] });
|
||||
toast.success('Resources updated successfully!');
|
||||
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: () => toast.error('Failed to update resources'),
|
||||
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'),
|
||||
});
|
||||
|
||||
// Handler to check upgrade cost before applying
|
||||
const handleScaleResources = () => {
|
||||
// If app doesn't have billing cycle (free/unmanaged), apply directly
|
||||
if (!app?.billingCycle) {
|
||||
scaleMutation.mutate(resourceForm);
|
||||
return;
|
||||
}
|
||||
// Otherwise, calculate cost first
|
||||
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 }) => {
|
||||
@@ -583,6 +723,293 @@ export default function AppDetailPage() {
|
||||
</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-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
</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={() => renewMutation.mutate(selectedCycle)}
|
||||
disabled={renewMutation.isPending || !renewalCostData?.costs || (walletData && renewalCostData?.costs && (
|
||||
(selectedCycle === 'hourly' && walletData.balance < renewalCostData.costs.hourly) ||
|
||||
(selectedCycle === 'monthly' && walletData.balance < renewalCostData.costs.monthly) ||
|
||||
(selectedCycle === 'yearly' && walletData.balance < renewalCostData.costs.yearly)
|
||||
))}
|
||||
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"
|
||||
>
|
||||
{renewMutation.isPending ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Processing...</>
|
||||
) : (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Renew</>
|
||||
)}
|
||||
</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-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm text-red-700">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-1" />
|
||||
Insufficient balance. Please charge your wallet first.
|
||||
<a href="/dashboard/wallet" className="underline ml-1 font-medium">Go to Wallet</a>
|
||||
</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={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending || (walletData && upgradeCostData.proratedAmount > walletData.balance)}
|
||||
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 ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Applying...</>
|
||||
) : upgradeCostData.proratedAmount > 0 ? (
|
||||
<><CreditCard className="w-4 h-4" /> Pay & Upgrade</>
|
||||
) : (
|
||||
<><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">
|
||||
@@ -898,7 +1325,7 @@ export default function AppDetailPage() {
|
||||
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">فقط امکان افزایش حجم وجود دارد (کاهش ممکن نیست)</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Only expansion is allowed (shrinking is not possible)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1107,6 +1534,155 @@ export default function AppDetailPage() {
|
||||
</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 Storage</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.database.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
</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.used / storageUsage.database.allocated) * 100 > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.database.used / storageUsage.database.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.database.used / storageUsage.database.allocated) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.database.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.database.available / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App Storage (WordPress wp-content) */}
|
||||
{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 Storage' : 'App Storage'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB / {(storageUsage.appStorage.allocated / (1024 * 1024 * 1024)).toFixed(1)} GB
|
||||
</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.used / storageUsage.appStorage.allocated) * 100 > 80
|
||||
? 'bg-red-500'
|
||||
: (storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100 > 50
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min((storageUsage.appStorage.used / storageUsage.appStorage.allocated) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Used: {(storageUsage.appStorage.used / (1024 * 1024 * 1024)).toFixed(2)} GB</span>
|
||||
<span>Available: {(storageUsage.appStorage.available / (1024 * 1024 * 1024)).toFixed(2)} GB</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.database && !storageUsage.appStorage && (
|
||||
<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" /> Scale Resources</h3>
|
||||
@@ -1171,11 +1747,11 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => scaleMutation.mutate(resourceForm)}
|
||||
disabled={scaleMutation.isPending}
|
||||
onClick={handleScaleResources}
|
||||
disabled={scaleMutation.isPending || calculateUpgradeCostMutation.isPending}
|
||||
className="btn-primary text-sm w-full disabled:opacity-50"
|
||||
>
|
||||
{scaleMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /> Applying...</> : <><RefreshCw className="w-3 h-3 inline" /> Apply Changes</>}
|
||||
{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>
|
||||
@@ -1303,30 +1879,45 @@ export default function AppDetailPage() {
|
||||
<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.</p>
|
||||
<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')}
|
||||
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={!!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"
|
||||
>
|
||||
<Archive className="w-3 h-3" /> Source Code
|
||||
{downloadingArtifact === 'source' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Archive className="w-3 h-3" /> Source Code</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{app.runtime === 'wordpress' && (
|
||||
{app.runtime?.toLowerCase() === 'wordpress' && (
|
||||
<button
|
||||
onClick={() => downloadCurrentArtifact('wp-content')}
|
||||
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={!!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"
|
||||
>
|
||||
<Archive className="w-3 h-3" /> wp-content
|
||||
{downloadingArtifact === 'wp-content' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Archive className="w-3 h-3" /> wp-content</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{app.databaseType !== 'none' && (
|
||||
{app.databaseType && app.databaseType.toLowerCase() !== 'none' && (
|
||||
<button
|
||||
onClick={() => downloadCurrentArtifact('database')}
|
||||
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={!!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"
|
||||
>
|
||||
<Database className="w-3 h-3" /> Database Dump
|
||||
{downloadingArtifact === 'database' ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> Downloading...</>
|
||||
) : (
|
||||
<><Database className="w-3 h-3" /> Database Dump</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user