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
+84 -16
View File
@@ -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>
+67 -8
View File
@@ -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>
+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>
);
}
+21
View File
@@ -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}
+23
View File
@@ -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