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:
@@ -5,8 +5,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application } from '@/types';
|
||||
import { Search, X, Package, Hexagon, User, Database, Box } from 'lucide-react';
|
||||
import type { Application, AppLifecycleStatus, BillingCycle } from '@/types';
|
||||
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
@@ -20,6 +20,45 @@ const statusColors: Record<string, string> = {
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
const lifecycleLabels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
suspended: 'Suspended — Unpaid',
|
||||
pending_deletion: 'Pending Deletion',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
const cycleLabels: Record<string, string> = {
|
||||
hourly: 'Hourly',
|
||||
monthly: 'Monthly',
|
||||
yearly: 'Yearly',
|
||||
};
|
||||
|
||||
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
if (!expiresAt) return { text: '—', urgent: false };
|
||||
const now = new Date();
|
||||
const exp = new Date(expiresAt);
|
||||
const diff = exp.getTime() - now.getTime();
|
||||
if (diff <= 0) return { text: 'Expired', urgent: true };
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return { text: `${days}d ${hours % 24}h`, urgent: days < 3 };
|
||||
if (hours > 0) return { text: `${hours}h`, urgent: hours < 6 };
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return { text: `${mins}m`, urgent: true };
|
||||
}
|
||||
|
||||
function formatDeletionDate(date?: string): string {
|
||||
if (!date) return '';
|
||||
return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function AdminAppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
@@ -52,9 +91,13 @@ export default function AdminAppsPage() {
|
||||
else if (status === 'failed' || status === 'build_failed') acc.failed++;
|
||||
else if (status === 'building' || status === 'deploying') acc.deploying++;
|
||||
else acc.pending++;
|
||||
// Lifecycle counts
|
||||
const lc = app.lifecycleStatus || 'active';
|
||||
if (lc === 'suspended') acc.suspended++;
|
||||
if (lc === 'pending_deletion') acc.pendingDeletion++;
|
||||
return acc;
|
||||
},
|
||||
{ running: 0, stopped: 0, failed: 0, deploying: 0, pending: 0 },
|
||||
{ running: 0, stopped: 0, failed: 0, deploying: 0, pending: 0, suspended: 0, pendingDeletion: 0 },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -101,7 +144,7 @@ export default function AdminAppsPage() {
|
||||
</div>
|
||||
|
||||
{/* Status Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<div className="card py-4 text-center border-l-4 border-l-green-500">
|
||||
<p className="text-2xl font-bold text-green-600">{statusCounts.running}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Running</p>
|
||||
@@ -122,6 +165,14 @@ export default function AdminAppsPage() {
|
||||
<p className="text-2xl font-bold text-red-600">{statusCounts.failed}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Failed</p>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-amber-500">
|
||||
<p className="text-2xl font-bold text-amber-600">{statusCounts.suspended}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Suspended</p>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-red-700">
|
||||
<p className="text-2xl font-bold text-red-700">{statusCounts.pendingDeletion}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Pending Del.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -170,18 +221,19 @@ export default function AdminAppsPage() {
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Application</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Owner</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Runtime</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Database</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Replicas</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Service</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Plan / Expiry</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
return (
|
||||
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
@@ -191,7 +243,7 @@ export default function AdminAppsPage() {
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors block">
|
||||
{app.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{app.id.slice(0, 8)}</span>
|
||||
<span className="text-xs text-gray-400 capitalize">{app.runtime}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
@@ -208,14 +260,30 @@ export default function AdminAppsPage() {
|
||||
<span className="text-xs text-gray-400 font-mono">{app.userId.slice(0, 8)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.databaseType}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
{lifecycle === 'pending_deletion' && app.scheduledDeletionAt && (
|
||||
<p className="text-xs text-red-500 mt-1">Delete: {formatDeletionDate(app.scheduledDeletionAt)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{app.billingCycle && (
|
||||
<span className="badge badge-purple text-xs">{cycleLabels[app.billingCycle] || app.billingCycle}</span>
|
||||
)}
|
||||
{app.planExpiresAt ? (
|
||||
<span className={`block text-xs mt-1 ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
{expiry.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">No plan</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { ServicePlan, BillingCycle, PricingResourceType } from '@/types';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const runtimeOptions = [
|
||||
@@ -322,6 +322,164 @@ export default function AdminBillingPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lifecycle Settings Sub-component ─────────────────────────────
|
||||
|
||||
function LifecycleSettingsSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [hourlyHours, setHourlyHours] = useState('');
|
||||
const [monthlyDays, setMonthlyDays] = useState('');
|
||||
const [yearlyDays, setYearlyDays] = useState('');
|
||||
|
||||
const { data: settings, isLoading } = useQuery<LifecycleSettings>({
|
||||
queryKey: ['lifecycle-settings'],
|
||||
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: any) => api.patch('/lifecycle/settings', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
||||
toast.success('Lifecycle settings updated');
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to save'),
|
||||
});
|
||||
|
||||
const startEditing = () => {
|
||||
if (settings) {
|
||||
setHourlyHours(String((settings.hourly.deleteAfterMs || 0) / 3600000));
|
||||
setMonthlyDays(String((settings.monthly.deleteAfterMs || 0) / 86400000));
|
||||
setYearlyDays(String((settings.yearly.deleteAfterMs || 0) / 86400000));
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const body: any = {};
|
||||
if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000;
|
||||
if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000;
|
||||
if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000;
|
||||
saveMutation.mutate(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-red-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Data Retention & Deletion Policy</h2>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
|
||||
<Edit2 className="w-3 h-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Configure how long user data is retained after plan expiration before permanent deletion.
|
||||
After a plan expires, the application is suspended (scaled to 0). If no payment is received within the grace period, the application and all its data are permanently deleted.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-6 text-gray-400">Loading...</div>
|
||||
) : editing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-900">Hourly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field mt-1 text-sm"
|
||||
value={hourlyHours}
|
||||
onChange={(e) => setHourlyHours(e.target.value)}
|
||||
min={1}
|
||||
placeholder="24"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-purple-600" />
|
||||
<h3 className="font-semibold text-purple-900">Monthly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field mt-1 text-sm"
|
||||
value={monthlyDays}
|
||||
onChange={(e) => setMonthlyDays(e.target.value)}
|
||||
min={1}
|
||||
placeholder="3"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Yearly Plans</h3>
|
||||
</div>
|
||||
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field mt-1 text-sm"
|
||||
value={yearlyDays}
|
||||
onChange={(e) => setYearlyDays(e.target.value)}
|
||||
min={1}
|
||||
placeholder="7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setEditing(false)} className="btn-ghost">Cancel</button>
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
|
||||
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
|
||||
<Clock className="w-4 h-4" /> Hourly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-blue-700 mt-2">
|
||||
{settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
||||
<span className="text-sm font-normal ml-1">hours</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
|
||||
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
|
||||
<Clock className="w-4 h-4" /> Monthly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-purple-700 mt-2">
|
||||
{settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
||||
<span className="text-sm font-normal ml-1">days</span>
|
||||
</p>
|
||||
<p className="text-xs text-purple-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
|
||||
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
|
||||
<Clock className="w-4 h-4" /> Yearly Plans
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-green-700 mt-2">
|
||||
{settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
||||
<span className="text-sm font-normal ml-1">days</span>
|
||||
</p>
|
||||
<p className="text-xs text-green-500 mt-1">after suspension → delete</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function AppDetailPage() {
|
||||
const handleRevisionRollback = async (rev: K8sRevision) => {
|
||||
const ok = await confirm({
|
||||
title: `Rollback to Revision ${rev.revision}?`,
|
||||
message: `This will instantly switch to:\n\nImage: ${rev.image}\n${rev.changeCause ? `Reason: ${rev.changeCause}` : ''}\n\nNo rebuild needed — takes effect in seconds.`,
|
||||
message: `This will rollback the Helm release to revision ${rev.revision}.\n${rev.changeCause ? `\nDescription: ${rev.changeCause}` : ''}\n\nNo rebuild needed — takes effect in seconds.`,
|
||||
confirmText: 'Rollback',
|
||||
variant: 'warning',
|
||||
});
|
||||
@@ -1244,7 +1244,7 @@ export default function AppDetailPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3">
|
||||
<p className="text-xs text-amber-700">
|
||||
<Zap className="w-3 h-3 inline" /> <strong>Instant rollback</strong> using Kubernetes deployment revisions. Switches the active container image in seconds — no rebuild needed. Up to 10 revisions are kept.
|
||||
<Zap className="w-3 h-3 inline" /> <strong>Instant rollback</strong> using Helm release revisions. Switches to a previous configuration in seconds — no rebuild needed. Up to 10 revisions are kept.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1272,10 +1272,7 @@ export default function AppDetailPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono truncate" title={rev.image}>{rev.image}</p>
|
||||
{rev.changeCause && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate" title={rev.changeCause}>{rev.changeCause}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1 truncate" title={rev.changeCause}>{rev.changeCause}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{new Date(rev.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box } from 'lucide-react';
|
||||
import type { Application, AppLifecycleStatus } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -18,6 +18,34 @@ const statusColors: Record<string, string> = {
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
const lifecycleLabels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
suspended: 'Suspended — Unpaid',
|
||||
pending_deletion: 'Pending Deletion',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
if (!expiresAt) return { text: '—', urgent: false };
|
||||
const now = new Date();
|
||||
const exp = new Date(expiresAt);
|
||||
const diff = exp.getTime() - now.getTime();
|
||||
if (diff <= 0) return { text: 'Expired', urgent: true };
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return { text: `${days}d ${hours % 24}h remaining`, urgent: days < 3 };
|
||||
if (hours > 0) return { text: `${hours}h remaining`, urgent: hours < 6 };
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return { text: `${mins}m remaining`, urgent: true };
|
||||
}
|
||||
|
||||
export default function AppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
@@ -89,17 +117,19 @@ export default function AppsPage() {
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Application</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Runtime</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Database</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Replicas</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Service Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Expiry</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
return (
|
||||
<tr key={app.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
|
||||
<td className="px-6 py-4">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
@@ -111,13 +141,27 @@ export default function AppsPage() {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.databaseType}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{app.replicas}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{app.planExpiresAt ? (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium ${expiry.urgent ? 'text-red-600' : 'text-gray-600'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{expiry.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">No plan</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
@@ -145,11 +189,13 @@ export default function AppsPage() {
|
||||
<div className="md:hidden grid gap-3">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card-hover"
|
||||
className={`card-hover ${lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -165,6 +211,19 @@ export default function AppsPage() {
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
{/* Lifecycle & Expiry row */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
{app.planExpiresAt && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{expiry.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
|
||||
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Fetch wallet balance for all authenticated users (shown in header)
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
@@ -237,6 +245,19 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Wallet balance */}
|
||||
<Link
|
||||
href="/dashboard/wallet"
|
||||
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors text-sm"
|
||||
title="Wallet Balance"
|
||||
>
|
||||
<Wallet className="w-3.5 h-3.5 text-primary-600" />
|
||||
<span className="font-bold text-gray-800">
|
||||
{walletData ? Number(walletData.balance).toLocaleString('en-US') : '...'}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs">T</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 font-medium">
|
||||
{user?.firstName} {user?.lastName}
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface Application {
|
||||
latestImageTag?: string;
|
||||
subdomain?: string;
|
||||
deployments?: Deployment[];
|
||||
// Billing & Lifecycle
|
||||
planId?: string;
|
||||
billingCycle?: BillingCycle;
|
||||
lifecycleStatus?: AppLifecycleStatus;
|
||||
planExpiresAt?: string;
|
||||
suspendedAt?: string;
|
||||
scheduledDeletionAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -67,6 +74,8 @@ export type DeploymentStatus =
|
||||
| 'stopped'
|
||||
| 'deleting';
|
||||
|
||||
export type AppLifecycleStatus = 'active' | 'suspended' | 'pending_deletion' | 'deleted';
|
||||
|
||||
export interface Cluster {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -325,3 +334,17 @@ export interface K8sRevisionData {
|
||||
revisions: K8sRevision[];
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
// ─── Lifecycle Settings ─────────────────────────────
|
||||
|
||||
export interface LifecycleCycleSettings {
|
||||
deleteAfterMs: number;
|
||||
deleteAfterHours?: number;
|
||||
deleteAfterDays?: number;
|
||||
}
|
||||
|
||||
export interface LifecycleSettings {
|
||||
hourly: LifecycleCycleSettings;
|
||||
monthly: LifecycleCycleSettings;
|
||||
yearly: LifecycleCycleSettings;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user