feat(frontend): lifecycle status, wallet balance, admin billing UI

- Wallet balance display in dashboard header
- Lifecycle status badges (color-coded) in apps list
- Plan expiry countdown column
- Admin apps: suspended/pending-deletion summary cards
- Admin billing: lifecycle settings management
- Updated TypeScript types for lifecycle and billing
This commit is contained in:
keyhan
2026-04-22 16:44:58 +03:30
parent 5f6eccc483
commit e10f9c5235
8 changed files with 636 additions and 45 deletions
+277 -12
View File
@@ -7,10 +7,23 @@ import api from '@/lib/api';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard } from 'lucide-react';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2 } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error';
const stageLabels: Record<DeployStage, string> = {
idle: '',
creating: 'Creating application...',
'uploading-source': 'Uploading source code...',
'uploading-db': 'Uploading database dump...',
paying: 'Processing payment...',
deploying: 'Starting deployment...',
done: 'Redirecting...',
error: 'An error occurred',
};
export default function DeployPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
@@ -48,7 +61,8 @@ export default function DeployPage() {
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
const dbDumpInputRef = useRef<HTMLInputElement>(null);
const [dbUploadProgress, setDbUploadProgress] = useState(0);
const [wpMode, setWpMode] = useState<'fresh' | 'migrate'>('fresh');
const [deployStage, setDeployStage] = useState<DeployStage>('idle');
const [wpMode, setWpMode] = useState<'fresh' | 'migrate' | 'public_html'>('fresh');
const [wpContentFile, setWpContentFile] = useState<File | null>(null);
const [isWpDragging, setIsWpDragging] = useState(false);
const wpFileInputRef = useRef<HTMLInputElement>(null);
@@ -96,6 +110,7 @@ export default function DeployPage() {
const walletPayMutation = useMutation({
mutationFn: async () => {
// First create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
@@ -103,9 +118,11 @@ export default function DeployPage() {
const res = await api.post('/applications', payload);
const appId = res.data.id;
// Upload source (regular apps or WordPress migrate)
const fileToUpload = form.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
@@ -116,6 +133,8 @@ export default function DeployPage() {
// Upload DB dump
if (form.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
@@ -125,24 +144,31 @@ export default function DeployPage() {
}
// Deduct from wallet
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Payment or deployment failed');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const gatewayPayMutation = useMutation({
mutationFn: async () => {
// Initiate gateway
setDeployStage('paying');
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Deploy: ${form.name} (${selectedCycle})`,
@@ -157,6 +183,7 @@ export default function DeployPage() {
});
// Now create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
@@ -164,9 +191,11 @@ export default function DeployPage() {
const res = await api.post('/applications', payload);
const appId = res.data.id;
// Upload source (regular apps or WordPress migrate)
const fileToUpload = form.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = form.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
@@ -177,6 +206,8 @@ export default function DeployPage() {
// Upload DB dump
if (form.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
@@ -186,30 +217,39 @@ export default function DeployPage() {
}
// Deduct from the wallet (which was just charged by gateway)
setDeployStage('paying');
await api.post(`/billing/wallet/pay/${appId}`, { amount: payAmount, cycle: selectedCycle });
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Payment failed');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const createMutation = useMutation({
mutationFn: async (data: CreateApplicationDto) => {
setDeployStage('creating');
const res = await api.post('/applications', data);
const appId = res.data.id;
// Upload zip file if selected
// Upload source (regular apps or WordPress migrate)
const fileToUpload = data.runtime === 'wordpress' ? (wpMode === 'migrate' ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = data.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
const formData = new FormData();
formData.append('file', fileToUpload);
await api.post(`/applications/${appId}/upload`, formData, {
@@ -222,6 +262,8 @@ export default function DeployPage() {
// Upload DB dump if provided and a database was requested
if (data.databaseType && data.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
formData.append('file', dbDumpFile);
await api.post(`/applications/${appId}/db-upload`, formData, {
@@ -235,13 +277,18 @@ export default function DeployPage() {
return res;
},
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Application created! Triggering deployment...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
onError: (err: any) => {
setDeployStage('error');
toast.error(err.response?.data?.message || 'Failed to create application');
setUploadProgress(0);
setDbUploadProgress(0);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
@@ -327,9 +374,9 @@ export default function DeployPage() {
const canNext = () => {
if (step === 0) {
if (form.name.length < 2) return false;
// WordPress: migrate mode requires wp-content file
// WordPress: migrate or public_html mode requires wp-content file
if (form.runtime === 'wordpress') {
if (wpMode === 'migrate' && !wpContentFile) return false;
if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false;
} else {
if (sourceMethod === 'upload' && !zipFile) return false;
if (sourceMethod === 'git' && !form.gitUrl) return false;
@@ -572,7 +619,7 @@ export default function DeployPage() {
{form.runtime === 'wordpress' && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Deployment Mode</label>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => { setWpMode('fresh'); setWpContentFile(null); }}
@@ -588,7 +635,7 @@ export default function DeployPage() {
</button>
<button
type="button"
onClick={() => setWpMode('migrate')}
onClick={() => { setWpMode('migrate'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'migrate' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
@@ -599,6 +646,19 @@ export default function DeployPage() {
Upload your WordPress files (wp-content, themes, plugins) and optionally a DB dump.
</p>
</button>
<button
type="button"
onClick={() => { setWpMode('public_html'); setWpContentFile(null); }}
className={`p-4 rounded-xl border-2 text-left transition-colors ${
wpMode === 'public_html' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<FolderUp className="w-5 h-5 text-purple-600" />
<p className="mt-2 font-semibold text-sm text-gray-900">Upload public_html</p>
<p className="text-xs text-gray-500 mt-1">
Upload your entire public_html directory (full WordPress root) and deploy.
</p>
</button>
</div>
{wpMode === 'fresh' && (
@@ -690,6 +750,83 @@ export default function DeployPage() {
/>
</div>
)}
{wpMode === 'public_html' && (
<div className="space-y-3">
<div className="p-3 bg-purple-50 border border-purple-200 rounded-xl">
<p className="text-xs text-gray-600">
<strong>Upload a ZIP</strong> of your entire <code className="bg-purple-100 px-1 rounded">public_html</code> directory (the full WordPress root):
</p>
<ul className="text-xs text-gray-500 mt-1 ml-4 list-disc space-y-0.5">
<li><code className="bg-purple-100 px-1 rounded">wp-admin/</code>, <code className="bg-purple-100 px-1 rounded">wp-includes/</code>, <code className="bg-purple-100 px-1 rounded">wp-content/</code></li>
<li><code className="bg-purple-100 px-1 rounded">wp-config.php</code>, <code className="bg-purple-100 px-1 rounded">.htaccess</code>, and all root PHP files</li>
</ul>
<p className="text-xs text-gray-500 mt-1.5">
The system auto-detects the full WordPress root and deploys it accordingly.
You can also upload a SQL database dump in the next step to restore your data.
</p>
</div>
{wpContentFile ? (
<div className="flex items-center justify-between p-4 bg-green-50 border border-green-200 rounded-xl">
<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">{wpContentFile.name}</p>
<p className="text-xs text-green-600">
{(wpContentFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<button
type="button"
onClick={() => {
setWpContentFile(null);
if (wpFileInputRef.current) wpFileInputRef.current.value = '';
}}
className="text-sm text-red-500 hover:text-red-700 font-medium"
>
Remove
</button>
</div>
) : (
<div
onDrop={handleWpDrop}
onDragOver={(e) => { e.preventDefault(); setIsWpDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsWpDragging(false); }}
onClick={() => wpFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isWpDragging
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
}`}
>
<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">
Drag & drop your public_html ZIP here
</p>
<p className="text-xs text-gray-500">
ZIP with full WordPress root (<strong>wp-admin/</strong>, <strong>wp-content/</strong>, ...) Max 200MB
</p>
</div>
</div>
)}
<input
ref={wpFileInputRef}
type="file"
accept=".zip,.tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleWpFileSelect(file);
e.target.value = '';
}}
/>
</div>
)}
</div>
)}
</div>
@@ -1520,6 +1657,134 @@ export default function DeployPage() {
)}
</div>
</div>
{/* Deploy Progress Overlay */}
{deployStage !== 'idle' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-6">
<div className="text-center">
{deployStage === 'error' ? (
<XCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
) : deployStage === 'done' ? (
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
) : (
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
)}
<h3 className="text-lg font-semibold text-gray-900">
{deployStage === 'error' ? 'Deployment Failed' : deployStage === 'done' ? 'Success!' : 'Deploying Application'}
</h3>
<p className="text-sm text-gray-500 mt-1">{stageLabels[deployStage]}</p>
</div>
{/* Stage Progress Steps */}
<div className="space-y-3">
{/* Creating */}
<div className="flex items-center gap-3">
{deployStage === 'creating' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['uploading-source', 'uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : deployStage === 'error' ? (
<XCircle className="w-5 h-5 text-red-400 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'creating' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Creating application
</span>
</div>
{/* Uploading source (only if we have a file) */}
{((form.runtime === 'wordpress' && (wpMode === 'migrate' || wpMode === 'public_html') && wpContentFile) || (form.runtime !== 'wordpress' && sourceMethod === 'upload' && zipFile)) && (
<div className="space-y-1">
<div className="flex items-center gap-3">
{deployStage === 'uploading-source' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['uploading-db', 'paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm flex-1 ${deployStage === 'uploading-source' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Uploading source code
{deployStage === 'uploading-source' && uploadProgress > 0 && (
<span className="text-primary-600 font-semibold ml-2">{uploadProgress}%</span>
)}
</span>
</div>
{deployStage === 'uploading-source' && (
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</div>
)}
{/* Uploading DB dump (only if we have a dump) */}
{dbDumpFile && form.databaseType !== 'none' && (
<div className="space-y-1">
<div className="flex items-center gap-3">
{deployStage === 'uploading-db' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['paying', 'deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm flex-1 ${deployStage === 'uploading-db' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Uploading database dump
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
<span className="text-primary-600 font-semibold ml-2">{dbUploadProgress}%</span>
)}
</span>
</div>
{deployStage === 'uploading-db' && (
<div className="ml-8 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${dbUploadProgress}%` }}
/>
</div>
)}
</div>
)}
{/* Payment (only if cost > 0) */}
{costData && costData.monthly > 0 && (
<div className="flex items-center gap-3">
{deployStage === 'paying' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : ['deploying', 'done'].includes(deployStage) ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'paying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Processing payment
</span>
</div>
)}
{/* Deploying */}
<div className="flex items-center gap-3">
{deployStage === 'deploying' ? (
<Loader2 className="w-5 h-5 text-primary-600 animate-spin shrink-0" />
) : deployStage === 'done' ? (
<CheckCircle className="w-5 h-5 text-green-500 shrink-0" />
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-200 shrink-0" />
)}
<span className={`text-sm ${deployStage === 'deploying' ? 'text-gray-900 font-medium' : 'text-gray-500'}`}>
Starting deployment
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}