add optinal apps

This commit is contained in:
keyhan
2026-04-23 15:26:49 +03:30
parent f481a57d8f
commit 38748b0827
16 changed files with 3091 additions and 144 deletions
+611 -20
View File
@@ -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>
+306 -18
View File
@@ -48,6 +48,10 @@ export default function DeployPage() {
replicas: 1,
port: 3000,
dbStorageSize: '1',
appStorageSize: '2',
enableRedis: false,
enableRabbitmq: false,
enableElasticsearch: false,
});
const [envKey, setEnvKey] = useState('');
const [envVal, setEnvVal] = useState('');
@@ -84,7 +88,7 @@ export default function DeployPage() {
// Cost calculation for the review step
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize],
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
queryFn: () => api.post('/billing/calculate', {
runtime: form.runtime,
databaseType: form.databaseType,
@@ -92,6 +96,10 @@ export default function DeployPage() {
memoryLimit: form.memoryLimit,
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}`,
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
}).then((r) => r.data),
enabled: step === 3,
});
@@ -115,6 +123,9 @@ export default function DeployPage() {
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -188,6 +199,9 @@ export default function DeployPage() {
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -312,6 +326,10 @@ export default function DeployPage() {
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
// Format appStorageSize with Gi suffix (all app types)
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
createMutation.mutate(payload);
};
@@ -361,6 +379,10 @@ export default function DeployPage() {
return;
}
setWpContentFile(file);
// Auto-suggest app storage size based on file size (add 50% buffer, minimum 2GB)
const fileSizeGb = file.size / (1024 * 1024 * 1024);
const suggestedSize = Math.max(2, Math.ceil(fileSizeGb * 1.5));
setForm((prev) => ({ ...prev, appStorageSize: String(suggestedSize) }));
toast.success(`WordPress files selected: ${file.name}`);
}, []);
@@ -451,30 +473,40 @@ export default function DeployPage() {
{/* Application Runtime */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Application Type</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-6 h-6 text-green-500" />, desc: 'Express, NestJS, Fastify...' },
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-6 h-6 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-6 h-6 text-blue-600" />, desc: 'Official image, wp-content' },
{ value: 'nodejs', label: 'Node.js', icon: <Hexagon className="w-5 h-5 text-green-500" />, desc: 'Express, NestJS, Fastify' },
{ value: 'laravel', label: 'Laravel', icon: <Hexagon className="w-5 h-5 text-orange-500" />, desc: 'PHP, Composer, Artisan' },
{ value: 'wordpress', label: 'WordPress', icon: <Hexagon className="w-5 h-5 text-blue-600" />, desc: 'Official image' },
{ value: 'go', label: 'Go', icon: <Hexagon className="w-5 h-5 text-cyan-500" />, desc: 'Gin, Echo, Fiber' },
{ value: 'python', label: 'Python', icon: <Hexagon className="w-5 h-5 text-yellow-500" />, desc: 'Flask, FastAPI' },
{ value: 'django', label: 'Django', icon: <Hexagon className="w-5 h-5 text-green-700" />, desc: 'Python web framework' },
{ value: 'php', label: 'PHP', icon: <Hexagon className="w-5 h-5 text-indigo-500" />, desc: 'Plain PHP apps' },
{ value: 'dotnet', label: '.NET', icon: <Hexagon className="w-5 h-5 text-purple-500" />, desc: 'ASP.NET Core' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
const updates: any = { runtime: opt.value as any, phpVersion: '' };
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
else if (opt.value === 'laravel') { updates.port = 8000; updates.runtimeVersion = ''; updates.phpVersion = '8.3'; }
else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
setForm({ ...form, ...updates });
// Reset WordPress-specific state when switching types
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
}}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
className={`p-3 rounded-xl border-2 text-left transition-colors ${
form.runtime === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
{opt.icon}
<p className="mt-2 font-semibold text-gray-900">{opt.label}</p>
<p className="mt-1.5 font-semibold text-sm text-gray-900">{opt.label}</p>
<p className="text-xs text-gray-500">{opt.desc}</p>
</button>
))}
@@ -898,29 +930,97 @@ export default function DeployPage() {
</div>
</>
)}
{form.runtime === 'go' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Go Version</label>
<select
className="input-field"
value={form.runtimeVersion || '1.22'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="1.23">Go 1.23 (Latest)</option>
<option value="1.22">Go 1.22 (Recommended)</option>
<option value="1.21">Go 1.21</option>
</select>
</div>
)}
{(form.runtime === 'python' || form.runtime === 'django') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Python Version</label>
<select
className="input-field"
value={form.runtimeVersion || '3.12'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="3.13">Python 3.13 (Latest)</option>
<option value="3.12">Python 3.12 (Recommended)</option>
<option value="3.11">Python 3.11</option>
<option value="3.10">Python 3.10</option>
</select>
</div>
)}
{form.runtime === 'php' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PHP Version</label>
<select
className="input-field"
value={form.phpVersion || '8.3'}
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
>
<option value="8.4">PHP 8.4 (Latest)</option>
<option value="8.3">PHP 8.3 (Recommended)</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div>
)}
{form.runtime === 'dotnet' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
<select
className="input-field"
value={form.runtimeVersion || '8.0'}
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
>
<option value="9.0">.NET 9.0 (Latest)</option>
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
<option value="7.0">.NET 7.0</option>
<option value="6.0">.NET 6.0 LTS</option>
</select>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Database
{form.runtime === 'wordpress' && (
<span className="text-xs text-blue-500 mr-2"> وردپرس به MySQL نیاز دارد</span>
<span className="text-xs text-blue-500 mr-2"> WordPress requires MySQL/MariaDB</span>
)}
</label>
<div className="grid grid-cols-3 gap-3 sm:gap-4">
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3 sm:gap-4">
{[
{ value: 'none', label: 'None', icon: <XCircle className="w-6 h-6 text-gray-400" /> },
{ value: 'postgresql', label: 'PostgreSQL', icon: <svg className="w-6 h-6 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mysql', label: 'MySQL', icon: <svg className="w-6 h-6 text-orange-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mariadb', label: 'MariaDB', icon: <svg className="w-6 h-6 text-teal-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19C3 20.66 7.03 22 12 22C16.97 22 21 20.66 21 19V5"/><path d="M3 12C3 13.66 7.03 15 12 15C16.97 15 21 13.66 21 12"/></svg> },
{ value: 'mongodb', label: 'MongoDB', icon: <svg className="w-6 h-6 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L12 22M12 2C7.58 4 5 8 5 12C5 16 7.58 20 12 22M12 2C16.42 4 19 8 19 12C19 16 16.42 20 12 22"/></svg> },
].map((opt) => {
const isWordPress = form.runtime === 'wordpress';
const disabled = isWordPress && opt.value !== 'mysql';
const disabled = isWordPress && !['mysql', 'mariadb'].includes(opt.value);
return (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => setForm({ ...form, databaseType: opt.value as any, dbVersion: opt.value === 'postgresql' ? '16' : opt.value === 'mysql' ? '8.0' : '' })}
onClick={() => {
let dbVersion = '';
if (opt.value === 'postgresql') dbVersion = '16';
else if (opt.value === 'mysql') dbVersion = '8.0';
else if (opt.value === 'mariadb') dbVersion = '11.4';
else if (opt.value === 'mongodb') dbVersion = '7.0';
setForm({ ...form, databaseType: opt.value as any, dbVersion });
}}
className={`p-4 rounded-xl border-2 text-center transition-colors ${
form.databaseType === opt.value ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
@@ -937,11 +1037,11 @@ export default function DeployPage() {
{form.databaseType !== 'none' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{form.databaseType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} Version
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
</label>
<select
className="input-field max-w-xs"
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : '8.0')}
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })}
>
{form.databaseType === 'postgresql' ? (
@@ -951,13 +1051,26 @@ export default function DeployPage() {
<option value="15">PostgreSQL 15</option>
<option value="14">PostgreSQL 14</option>
</>
) : (
) : form.databaseType === 'mysql' ? (
<>
<option value="9.0">MySQL 9.0 (Latest)</option>
<option value="8.4">MySQL 8.4 (LTS)</option>
<option value="8.0">MySQL 8.0</option>
<option value="5.7">MySQL 5.7</option>
</>
) : form.databaseType === 'mariadb' ? (
<>
<option value="11.4">MariaDB 11.4 (LTS)</option>
<option value="11.3">MariaDB 11.3</option>
<option value="10.11">MariaDB 10.11 (LTS)</option>
<option value="10.6">MariaDB 10.6</option>
</>
) : (
<>
<option value="7.0">MongoDB 7.0 (Latest)</option>
<option value="6.0">MongoDB 6.0</option>
<option value="5.0">MongoDB 5.0</option>
</>
)}
</select>
</div>
@@ -1126,14 +1239,177 @@ export default function DeployPage() {
<span className="text-sm font-medium text-gray-700">GB</span>
{dbDumpFile && (
<span className="text-xs text-blue-500">
پیشنهاد بر اساس حجم دامپ ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
Suggested based on dump size ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-400">حداقل ۱ گیگابایت بعد از ساخت فقط امکان افزایش حجم وجود دارد</p>
<p className="mt-1 text-xs text-gray-400">Minimum 1GB Only expansion is allowed after creation</p>
</div>
</div>
)}
{/* Optional Services Section */}
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-purple-600" />
<h3 className="font-semibold text-gray-900">Optional Services</h3>
<span className="text-xs text-gray-400">(Enable additional services for your app)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Redis */}
<button
type="button"
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableRedis
? 'border-red-400 bg-red-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">Redis</p>
<p className="text-xs text-gray-500">In-memory cache & store</p>
</div>
</div>
{form.enableRedis && (
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
</div>
)}
</button>
{/* RabbitMQ */}
<button
type="button"
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableRabbitmq
? 'border-orange-400 bg-orange-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">RabbitMQ</p>
<p className="text-xs text-gray-500">Message broker</p>
</div>
</div>
{form.enableRabbitmq && (
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
</div>
)}
</button>
{/* Elasticsearch */}
<button
type="button"
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.enableElasticsearch
? 'border-yellow-400 bg-yellow-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900">Elasticsearch</p>
<p className="text-xs text-gray-500">Logging & search</p>
</div>
</div>
{form.enableElasticsearch && (
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
<p>Logs collected via Fluent Bit sidecar</p>
</div>
)}
</button>
</div>
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
<p className="mt-4 text-xs text-gray-500">
Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared.
</p>
)}
</div>
{/* App Storage Size (all app types) */}
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
<div className="flex items-center gap-2 mb-3">
<FolderUp className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-gray-900">
{form.runtime === 'wordpress' ? 'Upload Storage (wp-content)'
: form.runtime === 'laravel' ? 'Storage Directory'
: 'Application Data Storage'}
</h3>
</div>
<p className="text-sm text-gray-600 mb-4">
{form.runtime === 'wordpress'
? 'This space is used for uploads, plugins, themes, and other WordPress files.'
: form.runtime === 'laravel'
? 'This space is used for uploads, logs, cache, and other Laravel storage files.'
: 'This space is used for persistent application data, uploads, and files.'}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={form.appStorageSize || '2'}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
setForm({ ...form, appStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{wpContentFile && form.runtime === 'wordpress' && (
<span className="text-xs text-green-600">
Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-2 text-xs text-gray-500">Minimum 1GB Recommended: 2GB or more</p>
</div>
</div>
)}
@@ -1509,6 +1785,18 @@ export default function DeployPage() {
<span className="text-sm text-gray-500">Port</span>
<span className="text-sm font-medium">{form.port}</span>
</div>
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Optional Services</span>
<span className="text-sm font-medium">
{[
form.enableRedis && 'Redis',
form.enableRabbitmq && 'RabbitMQ',
form.enableElasticsearch && 'Elasticsearch',
].filter(Boolean).join(', ')}
</span>
</div>
)}
{Object.keys(form.envVars || {}).length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Env Vars</span>
+16 -4
View File
@@ -13,14 +13,15 @@ export interface Application {
id: string;
name: string;
description?: string;
runtime: 'nodejs' | 'laravel' | 'wordpress';
databaseType: 'mysql' | 'postgresql' | 'none';
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
runtimeVersion?: string;
phpVersion?: string;
dbVersion?: string;
dbUsername?: string;
dbPassword?: string;
dbStorageSize?: string;
appStorageSize?: string;
gitUrl?: string;
gitToken?: string;
gitBranch?: string;
@@ -39,6 +40,11 @@ export interface Application {
latestImageTag?: string;
subdomain?: string;
deployments?: Deployment[];
// Optional services
enableRedis?: boolean;
enableRabbitmq?: boolean;
enableElasticsearch?: boolean;
logPaths?: string[];
// Billing & Lifecycle
planId?: string;
billingCycle?: BillingCycle;
@@ -100,14 +106,15 @@ export interface AuthResponse {
export interface CreateApplicationDto {
name: string;
description?: string;
runtime: 'nodejs' | 'laravel' | 'wordpress';
databaseType: 'mysql' | 'postgresql' | 'none';
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
runtimeVersion?: string;
phpVersion?: string;
dbVersion?: string;
dbUsername?: string;
dbPassword?: string;
dbStorageSize?: string;
appStorageSize?: string;
gitUrl?: string;
gitToken?: string;
gitBranch?: string;
@@ -120,6 +127,11 @@ export interface CreateApplicationDto {
port?: number;
clusterId?: string;
poolId?: string;
// Optional services
enableRedis?: boolean;
enableRabbitmq?: boolean;
enableElasticsearch?: boolean;
logPaths?: string[];
}
export interface ClusterPublic {