Add i18n foundation (fa-IR/en-US) and localize landing + auth.
Introduce path-prefixed locale routing under app/[lang] with a middleware that detects locale from cookie/Accept-Language (default fa-IR) and redirects. Add fa-IR (source of truth) and en-US dictionaries, a server getDictionary, a client I18nProvider/useT, locale-aware Link + router helpers, and a language switcher. The root [lang] layout sets html lang/dir and the per-locale font (Peyda for fa, Inter for en). Landing sections and the login/register/auth shell now read all copy from the dictionaries; dashboard localization follows in a later commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,709 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types';
|
||||
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
||||
import {
|
||||
DeletingCardOverlay,
|
||||
DeletingTableRowOverlay,
|
||||
deletingResourceMessage,
|
||||
deletingRowContentClass,
|
||||
} from '@/components/deleting-overlay';
|
||||
import { TruncatedText } from '@/components/truncated-text';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
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' });
|
||||
}
|
||||
|
||||
function migrationStatusBadgeClass(status: ApplicationMigrationJob['status']): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'badge-green';
|
||||
case 'failed':
|
||||
case 'rolled_back':
|
||||
return 'badge-red';
|
||||
case 'running':
|
||||
case 'rolling_back':
|
||||
return 'badge-blue';
|
||||
default:
|
||||
return 'badge-gray';
|
||||
}
|
||||
}
|
||||
|
||||
function formatMigrationStatus(status: ApplicationMigrationJob['status']): string {
|
||||
return status.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export default function AdminAppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 400);
|
||||
const [migrateApp, setMigrateApp] = useState<Application | null>(null);
|
||||
const [targetClusterId, setTargetClusterId] = useState('');
|
||||
|
||||
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['admin-applications', debouncedSearch],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get('/applications/all', { params: debouncedSearch ? { search: debouncedSearch } : {} })
|
||||
.then((r) => r.data),
|
||||
});
|
||||
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['admin-applications']],
|
||||
successMessage: 'Application deleted',
|
||||
});
|
||||
|
||||
const { data: clusters = [] } = useQuery<Cluster[]>({
|
||||
queryKey: ['admin-clusters'],
|
||||
queryFn: () => api.get('/clusters').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: migrations = [] } = useQuery<ApplicationMigrationJob[]>({
|
||||
queryKey: ['application-migrations'],
|
||||
queryFn: () => api.get('/application-migrations').then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const selectedMigration = migrateApp
|
||||
? migrations.find((migration) => migration.applicationId === migrateApp.id)
|
||||
: undefined;
|
||||
const clusterById = new Map(clusters.map((cluster) => [cluster.id, cluster]));
|
||||
const currentCluster = migrateApp?.clusterId ? clusterById.get(migrateApp.clusterId) : undefined;
|
||||
const targetClusters = migrateApp
|
||||
? clusters.filter((cluster) => cluster.id !== migrateApp.clusterId)
|
||||
: [];
|
||||
|
||||
const { data: selectedMigrationEvents = [] } = useQuery<ApplicationMigrationEvent[]>({
|
||||
queryKey: ['application-migration-events', selectedMigration?.id],
|
||||
queryFn: () => api.get(`/application-migrations/${selectedMigration!.id}/events`).then((r) => r.data),
|
||||
enabled: !!selectedMigration?.id,
|
||||
refetchInterval: selectedMigration && ['queued', 'running', 'rolling_back'].includes(selectedMigration.status) ? 3000 : false,
|
||||
});
|
||||
|
||||
const migrateMutation = useMutation({
|
||||
mutationFn: ({ appId, clusterId }: { appId: string; clusterId: string }) =>
|
||||
api.post(`/application-migrations/applications/${appId}`, {
|
||||
targetClusterId: clusterId,
|
||||
migrateStorage: true,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
|
||||
toast.success('Migration job queued');
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to queue migration'),
|
||||
});
|
||||
|
||||
const retryMigration = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
|
||||
toast.success('Migration retry queued');
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to retry migration'),
|
||||
});
|
||||
|
||||
// Compute status counts from apps
|
||||
const statusCounts = apps.reduce(
|
||||
(acc, app) => {
|
||||
const status = app.deployments?.[0]?.status || 'pending';
|
||||
if (status === 'running') acc.running++;
|
||||
else if (status === 'stopped') acc.stopped++;
|
||||
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, suspended: 0, pendingDeletion: 0 },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-56" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="card py-4">
|
||||
<div className="skeleton h-8 w-12 mx-auto mb-2" />
|
||||
<div className="skeleton h-3 w-16 mx-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton h-10 w-full rounded-xl" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-56" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">All Applications</h1>
|
||||
<p className="page-subtitle">
|
||||
{apps.length} application{apps.length !== 1 ? 's' : ''}
|
||||
{debouncedSearch && ` matching "${debouncedSearch}"`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Summary Cards */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-blue-500">
|
||||
<p className="text-2xl font-bold text-blue-600">{statusCounts.deploying}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Deploying</p>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-yellow-500">
|
||||
<p className="text-2xl font-bold text-yellow-600">{statusCounts.pending}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Pending</p>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-gray-400">
|
||||
<p className="text-2xl font-bold text-gray-500">{statusCounts.stopped}</p>
|
||||
<p className="text-xs text-gray-500 font-medium mt-1">Stopped</p>
|
||||
</div>
|
||||
<div className="card py-4 text-center border-l-4 border-l-red-500">
|
||||
<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 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by app name, user name, email, or user ID…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
{debouncedSearch ? (
|
||||
<>
|
||||
<p className="text-gray-600 text-lg font-medium">No applications found</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
No results for "{debouncedSearch}". Try a different search.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">No applications have been created by any user.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden lg:block table-wrapper overflow-x-auto">
|
||||
<table className="min-w-[1180px] w-full table-fixed divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-[300px] min-w-[300px]">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">Status</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">Cluster</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Migration</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 min-w-[200px]">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);
|
||||
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
|
||||
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
|
||||
const rowDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<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' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
|
||||
>
|
||||
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-[300px] group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<TruncatedText className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{app.name}
|
||||
</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400 capitalize">{app.runtime}</TruncatedText>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
{app.user ? (
|
||||
<div className="min-w-0">
|
||||
<TruncatedText className="text-sm font-medium text-gray-900">
|
||||
{`${app.user.firstName} ${app.user.lastName}`}
|
||||
</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400">{app.user.email}</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-300 font-mono">{app.userId}</TruncatedText>
|
||||
</div>
|
||||
) : (
|
||||
<TruncatedText className="text-xs text-gray-400 font-mono">{app.userId}</TruncatedText>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[120px] text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<span className={`badge max-w-full truncate ${statusColors[latestStatus] || 'badge-gray'}`} title={latestStatus}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[180px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<span
|
||||
className={`inline-flex max-w-full items-center gap-1 truncate px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}
|
||||
title={lifecycleLabels[lifecycle] || lifecycle}
|
||||
>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3 shrink-0" />}
|
||||
<span className="truncate">{lifecycleLabels[lifecycle] || lifecycle}</span>
|
||||
</span>
|
||||
{lifecycle === 'pending_deletion' && app.scheduledDeletionAt && (
|
||||
<TruncatedText className="text-xs text-red-500 mt-1">
|
||||
{`Delete: ${formatDeletionDate(app.scheduledDeletionAt)}`}
|
||||
</TruncatedText>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
{assignedCluster ? (
|
||||
<div className="min-w-0">
|
||||
<TruncatedText className="text-sm font-medium text-gray-900">{assignedCluster.name}</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400">
|
||||
{`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}/${assignedCluster.healthStatus || 'unknown'}`}
|
||||
</TruncatedText>
|
||||
</div>
|
||||
) : app.clusterId ? (
|
||||
<div className="min-w-0">
|
||||
<TruncatedText className="text-sm font-medium text-gray-700">Unknown cluster</TruncatedText>
|
||||
<TruncatedText className="text-xs text-gray-400 font-mono">{app.clusterId}</TruncatedText>
|
||||
</div>
|
||||
) : (
|
||||
<TruncatedText className="text-xs text-gray-400">Not assigned</TruncatedText>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
{latestMigration ? (
|
||||
<span
|
||||
className={`badge text-xs capitalize max-w-full truncate ${migrationStatusBadgeClass(latestMigration.status)}`}
|
||||
title={formatMigrationStatus(latestMigration.status)}
|
||||
>
|
||||
{formatMigrationStatus(latestMigration.status)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
{app.billingCycle && (
|
||||
<span
|
||||
className="badge badge-purple text-xs max-w-full truncate"
|
||||
title={cycleLabels[app.billingCycle] || app.billingCycle}
|
||||
>
|
||||
{cycleLabels[app.billingCycle] || app.billingCycle}
|
||||
</span>
|
||||
)}
|
||||
{app.planExpiresAt ? (
|
||||
<TruncatedText className={`text-xs mt-1 ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
{expiry.text}
|
||||
</TruncatedText>
|
||||
) : (
|
||||
<TruncatedText className="text-xs text-gray-400">No plan</TruncatedText>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-right ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMigrateApp(app);
|
||||
setTargetClusterId('');
|
||||
}}
|
||||
disabled={!app.clusterId}
|
||||
className="btn-secondary text-xs px-3 py-1.5 inline-flex items-center gap-1 disabled:opacity-50"
|
||||
title={!app.clusterId ? 'Application is not assigned to a cluster' : undefined}
|
||||
>
|
||||
<ArrowRightLeft className="w-3 h-3" /> Migrate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors font-medium disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{rowDeleting && (
|
||||
<DeletingTableRowOverlay
|
||||
colSpan={8}
|
||||
message={deletingResourceMessage('application', app.name)}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile / tablet cards */}
|
||||
<div className="lg:hidden grid gap-3">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
|
||||
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
|
||||
const cardDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={`relative card space-y-3 ${lifecycle === 'suspended' ? 'border-amber-200/80' : lifecycle === 'pending_deletion' ? 'border-red-200/80' : ''} ${cardDeleting ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
{cardDeleting && (
|
||||
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
|
||||
)}
|
||||
<div className={cardDeleting ? deletingRowContentClass : undefined}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<TruncatedText className="font-semibold text-gray-900">{app.name}</TruncatedText>
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<span className={`badge shrink-0 ${statusColors[latestStatus] || 'badge-gray'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{app.user && (
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<p className="flex items-center gap-1 font-medium text-gray-700">
|
||||
<User className="w-3 h-3 shrink-0" />
|
||||
{app.user.firstName} {app.user.lastName}
|
||||
</p>
|
||||
<TruncatedText>{app.user.email}</TruncatedText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
{app.billingCycle && (
|
||||
<span className="badge badge-purple">{cycleLabels[app.billingCycle] || app.billingCycle}</span>
|
||||
)}
|
||||
{latestMigration && (
|
||||
<span className={`badge capitalize ${migrationStatusBadgeClass(latestMigration.status)}`}>
|
||||
{formatMigrationStatus(latestMigration.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lifecycle === 'pending_deletion' && app.scheduledDeletionAt && (
|
||||
<p className="text-xs text-red-600">Delete: {formatDeletionDate(app.scheduledDeletionAt)}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="text-gray-400 block">Cluster</span>
|
||||
<TruncatedText className="font-medium text-gray-800">
|
||||
{assignedCluster?.name || (app.clusterId ? 'Unknown' : 'Not assigned')}
|
||||
</TruncatedText>
|
||||
{assignedCluster && (
|
||||
<TruncatedText className="text-gray-400 mt-0.5">
|
||||
{`${assignedCluster.region || 'N/A'} · ${assignedCluster.status}`}
|
||||
</TruncatedText>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400 block">Plan</span>
|
||||
{app.planExpiresAt ? (
|
||||
<span className={expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-700'}>{expiry.text}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">No plan</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3 border-t border-gray-100">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMigrateApp(app);
|
||||
setTargetClusterId('');
|
||||
}}
|
||||
disabled={!app.clusterId}
|
||||
className="btn-secondary text-xs px-3 py-1.5 inline-flex items-center gap-1 disabled:opacity-50"
|
||||
title={!app.clusterId ? 'Application is not assigned to a cluster' : undefined}
|
||||
>
|
||||
<ArrowRightLeft className="w-3 h-3" /> Migrate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
message: `Are you sure you want to delete "${app.name}"?`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 font-medium disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{migrateApp && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6 space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5" /> Migrate Application
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Move <span className="font-medium">{migrateApp.name}</span> to a healthy target cluster with logs, retry, and rollback.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMigrateApp(null)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Target Cluster</label>
|
||||
<div className="mb-3 rounded-xl bg-gray-50 border border-gray-100 p-3">
|
||||
<p className="text-xs font-medium text-gray-500">Current cluster</p>
|
||||
<p className="text-sm font-semibold text-gray-900 mt-1">
|
||||
{currentCluster?.name || (migrateApp.clusterId ? `Unknown cluster (${migrateApp.clusterId.slice(0, 8)})` : 'Not assigned')}
|
||||
</p>
|
||||
{currentCluster && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{currentCluster.region || 'N/A'} · {currentCluster.status}/{currentCluster.healthStatus || 'unknown'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className="input-field"
|
||||
value={targetClusterId}
|
||||
onChange={(e) => setTargetClusterId(e.target.value)}
|
||||
>
|
||||
<option value="">Select healthy cluster</option>
|
||||
{targetClusters
|
||||
.map((cluster) => (
|
||||
<option
|
||||
key={cluster.id}
|
||||
value={cluster.id}
|
||||
disabled={cluster.status !== 'active' || cluster.healthStatus !== 'healthy'}
|
||||
>
|
||||
{cluster.name} · {cluster.region || 'N/A'} · {cluster.status}/{cluster.healthStatus || 'unknown'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
The current cluster is excluded. Migration is blocked for unhealthy, inactive, or maintenance clusters.
|
||||
</p>
|
||||
{targetClusters.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
No other cluster is available as a migration target.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedMigration && (
|
||||
<div className="rounded-xl border border-gray-200 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">Latest migration</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedMigration.currentStep || 'queued'} · attempts {selectedMigration.attempts}/{selectedMigration.maxAttempts}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`badge ${
|
||||
selectedMigration.status === 'completed'
|
||||
? 'badge-green'
|
||||
: selectedMigration.status === 'failed' || selectedMigration.status === 'rolled_back'
|
||||
? 'badge-red'
|
||||
: 'badge-blue'
|
||||
}`}>
|
||||
{selectedMigration.status}
|
||||
</span>
|
||||
</div>
|
||||
{selectedMigration.errorMessage && (
|
||||
<p className="text-sm text-red-600">{selectedMigration.errorMessage}</p>
|
||||
)}
|
||||
<div className="max-h-40 overflow-auto rounded-lg bg-gray-50 p-3 space-y-2">
|
||||
{selectedMigrationEvents.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">No events yet.</p>
|
||||
) : selectedMigrationEvents.map((event) => (
|
||||
<div key={event.id} className="text-xs">
|
||||
<span className="font-mono text-gray-400">{new Date(event.createdAt).toLocaleTimeString()}</span>
|
||||
<span className={`ml-2 font-medium ${
|
||||
event.level === 'error' ? 'text-red-600' : event.level === 'warn' ? 'text-amber-600' : 'text-gray-700'
|
||||
}`}>
|
||||
{event.step}
|
||||
</span>
|
||||
<span className="ml-2 text-gray-600">{event.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{['failed', 'rolled_back'].includes(selectedMigration.status) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryMigration.mutate(selectedMigration.id)}
|
||||
disabled={retryMigration.isPending}
|
||||
className="btn-secondary text-sm inline-flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${retryMigration.isPending ? 'animate-spin' : ''}`} />
|
||||
Retry migration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setMigrateApp(null)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => migrateMutation.mutate({ appId: migrateApp.id, clusterId: targetClusterId })}
|
||||
disabled={!targetClusterId || migrateMutation.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{migrateMutation.isPending ? 'Queueing...' : 'Start Migration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingRateRow,
|
||||
OptionalServiceProfileRow,
|
||||
CustomDomainCatalogRow,
|
||||
BillingCycle,
|
||||
PricingResourceType,
|
||||
LifecycleSettings,
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||
|
||||
const resourceLabels: Record<PricingResourceType, string> = {
|
||||
base_fee: 'Base fee',
|
||||
cpu_per_core: 'CPU (per core)',
|
||||
memory_per_gb: 'Memory (per GB)',
|
||||
storage_per_gb: 'Storage (per GB)',
|
||||
database_addon: 'Database addon',
|
||||
redis_addon: 'Redis',
|
||||
rabbitmq_addon: 'RabbitMQ',
|
||||
elasticsearch_addon: 'Elasticsearch',
|
||||
custom_domain_addon: 'Custom domain + SSL',
|
||||
};
|
||||
|
||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||
|
||||
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
|
||||
function toPricingCatalogPatch(catalog: PricingCatalog) {
|
||||
const optionalServices: Record<
|
||||
string,
|
||||
{ service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] }
|
||||
> = {};
|
||||
for (const [key, entry] of Object.entries(catalog.optionalServices)) {
|
||||
optionalServices[key] = {
|
||||
service: entry.service ?? key,
|
||||
profile: entry.profile,
|
||||
rates: entry.rates,
|
||||
};
|
||||
}
|
||||
return {
|
||||
runtimes: catalog.runtimes,
|
||||
optionalServices,
|
||||
customDomain: catalog.customDomain,
|
||||
};
|
||||
}
|
||||
|
||||
function formatApiError(err: unknown, fallback: string): string {
|
||||
if (!err || typeof err !== 'object' || !('response' in err)) return fallback;
|
||||
const message = (err as { response?: { data?: { message?: string | string[] } } }).response
|
||||
?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
|
||||
const runtimes: PricingCatalog['runtimes'] = {};
|
||||
for (const key of Object.keys(catalog.runtimes)) {
|
||||
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
|
||||
}
|
||||
const optionalServices: PricingCatalog['optionalServices'] = {};
|
||||
for (const key of Object.keys(catalog.optionalServices)) {
|
||||
const entry = catalog.optionalServices[key];
|
||||
optionalServices[key] = {
|
||||
...entry,
|
||||
profile: { ...entry.profile },
|
||||
rates: entry.rates.map((r) => ({ ...r })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...catalog,
|
||||
runtimes,
|
||||
optionalServices,
|
||||
customDomain: { ...catalog.customDomain },
|
||||
};
|
||||
}
|
||||
|
||||
function PricingMatrixTable({
|
||||
rows,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
rows: PricingRateRow[];
|
||||
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Resource</th>
|
||||
{cycles.map((cycle) => (
|
||||
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
|
||||
{cycle} (T)
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.resourceType} className="border-t border-gray-100">
|
||||
<td className="p-3 font-medium text-gray-900">
|
||||
{resourceLabels[row.resourceType]}
|
||||
</td>
|
||||
{cycles.map((cycle) => {
|
||||
const field =
|
||||
cycle === 'hourly'
|
||||
? 'hourlyPrice'
|
||||
: cycle === 'monthly'
|
||||
? 'monthlyPrice'
|
||||
: 'yearlyPrice';
|
||||
const val = row[field];
|
||||
return (
|
||||
<td key={cycle} className="p-3">
|
||||
{readOnly ? (
|
||||
<span className="font-mono text-gray-700">
|
||||
{Number(val).toLocaleString('en-US')}
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input-field w-full max-w-[120px]"
|
||||
value={val}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
row.resourceType,
|
||||
cycle,
|
||||
e.target.value === '' ? 0 : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeployDefaultsFields({
|
||||
service,
|
||||
profile,
|
||||
readOnly,
|
||||
onUpdate,
|
||||
}: {
|
||||
service: string;
|
||||
profile: OptionalServiceProfileRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
|
||||
}) {
|
||||
const isLogging = service === 'elasticsearch';
|
||||
|
||||
if (isLogging) {
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
placeholder="50m"
|
||||
value={profile.logShipperCpuLimit ?? ''}
|
||||
onChange={(e) => onUpdate({ logShipperCpuLimit: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
placeholder="64Mi"
|
||||
value={profile.logShipperMemoryLimit ?? ''}
|
||||
onChange={(e) => onUpdate({ logShipperMemoryLimit: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
Shown when a user enables this service in deploy. They can change CPU, memory, and storage in
|
||||
Resources & Configuration; actual billing uses their choices × unit prices below.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">CPU request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuRequest || '50m'}
|
||||
onChange={(e) => onUpdate({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m</option>
|
||||
<option value="100m">100m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">CPU limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.cpuLimit}
|
||||
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Memory request</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryRequest || '64Mi'}
|
||||
onChange={(e) => onUpdate({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">Memory limit</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={profile.memoryLimit}
|
||||
onChange={(e) => onUpdate({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs font-medium text-gray-600">Storage (Gi)</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
className="input-field w-full max-w-[140px] text-sm mt-0.5"
|
||||
value={profile.storageGi}
|
||||
onChange={(e) =>
|
||||
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomDomainPricing({
|
||||
customDomain,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
customDomain: CustomDomainCatalogRow;
|
||||
readOnly: boolean;
|
||||
onChange: (cycle: BillingCycle, value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{cycles.map((cycle) => {
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
return (
|
||||
<div key={cycle}>
|
||||
<label className="text-xs text-gray-500 capitalize">{cycle} (T)</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
value={customDomain[field]}
|
||||
onChange={(e) =>
|
||||
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
||||
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<PricingCatalog | null>(null);
|
||||
|
||||
const { data: catalog, isLoading } = useQuery<PricingCatalog>({
|
||||
queryKey: ['pricing-catalog'],
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const options = catalog?.runtimeOptions ?? [];
|
||||
if (options.length === 0) return;
|
||||
if (!options.some((o) => o.value === activeRuntime)) {
|
||||
setActiveRuntime(options[0].value);
|
||||
}
|
||||
}, [catalog, activeRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
const options = catalog?.optionalServiceOptions ?? [];
|
||||
if (options.length === 0) return;
|
||||
if (!options.some((o) => o.value === activeOptionalService)) {
|
||||
setActiveOptionalService(options[0].value);
|
||||
}
|
||||
}, [catalog, activeOptionalService]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (catalog: PricingCatalog) =>
|
||||
api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
||||
toast.success('Billing plans saved');
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(formatApiError(err, 'Failed to save billing plans'));
|
||||
},
|
||||
});
|
||||
|
||||
const display = editing && draft ? draft : catalog;
|
||||
const runtimeTabs = display?.runtimeOptions ?? catalog?.runtimeOptions ?? [];
|
||||
|
||||
const startEdit = () => {
|
||||
if (!catalog) return;
|
||||
setDraft(cloneCatalog(catalog));
|
||||
if (!activeRuntime && catalog.runtimeOptions[0]) {
|
||||
setActiveRuntime(catalog.runtimeOptions[0].value);
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const updateRuntimePrice = (
|
||||
resourceType: PricingResourceType,
|
||||
cycle: BillingCycle,
|
||||
value: number,
|
||||
) => {
|
||||
if (!draft) return;
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
setDraft({
|
||||
...draft,
|
||||
runtimes: {
|
||||
...draft.runtimes,
|
||||
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) =>
|
||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateOptionalProfile = (patch: Partial<OptionalServiceProfileRow>) => {
|
||||
if (!draft) return;
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
profile: { ...entry.profile, ...patch },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateOptionalRate = (
|
||||
resourceType: PricingResourceType,
|
||||
cycle: BillingCycle,
|
||||
value: number,
|
||||
) => {
|
||||
if (!draft) return;
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
rates: entry.rates.map((row) =>
|
||||
row.resourceType === resourceType ? { ...row, [field]: value } : row,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateCustomDomain = (cycle: BillingCycle, value: number) => {
|
||||
if (!draft) return;
|
||||
const field =
|
||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||
setDraft({
|
||||
...draft,
|
||||
customDomain: { ...draft.customDomain, [field]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => {
|
||||
if (!draft) return;
|
||||
if (scope === 'runtime') {
|
||||
setDraft({
|
||||
...draft,
|
||||
runtimes: {
|
||||
...draft.runtimes,
|
||||
[activeRuntime]: (draft.runtimes[activeRuntime] ?? []).map((row) => ({
|
||||
...row,
|
||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const entry = draft.optionalServices[activeOptionalService];
|
||||
if (!entry) return;
|
||||
setDraft({
|
||||
...draft,
|
||||
optionalServices: {
|
||||
...draft.optionalServices,
|
||||
[activeOptionalService]: {
|
||||
...entry,
|
||||
rates: entry.rates.map((row) => ({
|
||||
...row,
|
||||
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft) return;
|
||||
saveMutation.mutate(draft);
|
||||
};
|
||||
|
||||
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
|
||||
const optionalServiceTabs = display?.optionalServiceOptions ?? [];
|
||||
const activeOptionalEntry = display?.optionalServices[activeOptionalService];
|
||||
const optionalRateRows = activeOptionalEntry?.rates ?? [];
|
||||
const customDomain = display?.customDomain;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6" /> Billing Plans
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
|
||||
their usage × these rates.
|
||||
</p>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={!catalog}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" /> Edit plans
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
}}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
|
||||
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">How billing works</p>
|
||||
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
||||
<li>
|
||||
<strong>Applications</strong> — user picks runtime resources in deploy; you set price per
|
||||
core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for
|
||||
example half the per-GB rate at half a gigabyte of memory).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Optional services (Redis, RabbitMQ)</strong> — same unit matrix per service as
|
||||
runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in
|
||||
proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Deploy defaults</strong> — optional prefill only; changing them does not change
|
||||
what existing apps pay unless the user chose those values at deploy.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
||||
) : !display ? (
|
||||
<div className="text-center py-12 text-gray-400">No pricing data</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Application runtimes</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{runtimeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setActiveRuntime(tab.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeRuntime === tab.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} — unit prices
|
||||
</h3>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('runtime')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PricingMatrixTable
|
||||
rows={runtimeRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateRuntimePrice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Unit pricing per service (hourly / monthly / yearly)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{optionalServiceTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => setActiveOptionalService(tab.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeOptionalService === tab.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
{optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} — unit prices
|
||||
</h3>
|
||||
{editing && activeOptionalEntry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillYearlyFromMonthly('optional')}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
Fill yearly from monthly ×12
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeOptionalEntry && (
|
||||
<PricingMatrixTable
|
||||
rows={optionalRateRows}
|
||||
readOnly={!editing}
|
||||
onChange={updateOptionalRate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Optional services — deploy defaults</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Prefill CPU, memory, and storage when a user enables each service in the deploy wizard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
|
||||
{optionalServiceTabs.map((tab) => (
|
||||
<button
|
||||
key={`defaults-${tab.value}`}
|
||||
type="button"
|
||||
onClick={() => setActiveOptionalService(tab.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeOptionalService === tab.value
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeOptionalEntry && (
|
||||
<DeployDefaultsFields
|
||||
service={activeOptionalService}
|
||||
profile={activeOptionalEntry.profile}
|
||||
readOnly={!editing}
|
||||
onUpdate={updateOptionalProfile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customDomain && (
|
||||
<div className="card space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Add-ons</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Flat fees not tied to a runtime</p>
|
||||
</div>
|
||||
</div>
|
||||
<CustomDomainPricing
|
||||
customDomain={customDomain}
|
||||
readOnly={!editing}
|
||||
onChange={updateCustomDomain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: Record<string, number>) => api.patch('/lifecycle/settings', body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
||||
toast.success('Lifecycle settings updated');
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(formatApiError(err, '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: Record<string, number> = {};
|
||||
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.
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Cluster, ClusterResources } from '@/types';
|
||||
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
const { data, isLoading, error } = useQuery<ClusterResources>({
|
||||
queryKey: ['cluster-resources', clusterId],
|
||||
queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-gray-500">Loading resources...</div>;
|
||||
if (error) return <div className="p-4 text-sm text-red-500">Failed to load resources</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const cpuCap = parseFloat(data.totalCpuCapacity);
|
||||
const cpuAlloc = parseFloat(data.totalCpuAllocatable);
|
||||
const memCap = parseFloat(data.totalMemoryCapacity);
|
||||
const memAlloc = parseFloat(data.totalMemoryAllocatable);
|
||||
const cpuUsedPct = cpuCap > 0 ? ((cpuCap - cpuAlloc) / cpuCap * 100) : 0;
|
||||
const memUsedPct = memCap > 0 ? ((memCap - memAlloc) / memCap * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 space-y-4 animate-fade-in">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
|
||||
<div className="text-xs text-blue-600">Nodes</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-700">{data.podCount}</div>
|
||||
<div className="text-xs text-purple-600">Pods</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{data.appCount}</div>
|
||||
<div className="text-xs text-green-600">Apps</div>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-orange-700">{data.totalCpuCapacity}</div>
|
||||
<div className="text-xs text-orange-600">Total CPU</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory bars */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">CPU Reserved</span>
|
||||
<span className="font-medium">{cpuUsedPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${cpuUsedPct > 80 ? 'bg-red-500' : cpuUsedPct > 60 ? 'bg-yellow-500' : 'bg-blue-500'}`}
|
||||
style={{ width: `${Math.min(cpuUsedPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.totalCpuCapacity} capacity · {data.totalCpuAllocatable} allocatable
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">Memory Reserved</span>
|
||||
<span className="font-medium">{memUsedPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${memUsedPct > 80 ? 'bg-red-500' : memUsedPct > 60 ? 'bg-yellow-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${Math.min(memUsedPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.totalMemoryCapacity} capacity · {data.totalMemoryAllocatable} allocatable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nodes table */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Nodes</h4>
|
||||
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Roles</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">CPU (Cap / Alloc)</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Memory (Cap / Alloc)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{data.nodes.map((node) => (
|
||||
<tr key={node.name} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-mono text-xs">{node.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
node.status === 'Ready' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{node.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{node.roles}</td>
|
||||
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.cpuCapacity} / {node.cpuAllocatable}</td>
|
||||
<td className="px-4 py-2 text-gray-600 font-mono text-xs">{node.memoryCapacity} / {node.memoryAllocatable}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const e = err as { response?: { data?: { message?: string | string[] } } };
|
||||
const msg = e?.response?.data?.message;
|
||||
if (Array.isArray(msg)) return msg.join(', ');
|
||||
if (typeof msg === 'string' && msg.trim()) return msg;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
interface ClusterToolField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'email';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
interface ClusterTool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
dependencies: string[];
|
||||
installFields: ClusterToolField[];
|
||||
status: 'not_installed' | 'installing' | 'installed' | 'failed' | 'unknown';
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOOL_STATUS_BADGE: Record<ClusterTool['status'], { label: string; cls: string }> = {
|
||||
installed: { label: 'Installed', cls: 'badge-green' },
|
||||
installing: { label: 'Installing…', cls: 'badge-yellow' },
|
||||
failed: { label: 'Failed', cls: 'badge-red' },
|
||||
not_installed: { label: 'Not installed', cls: 'badge-gray' },
|
||||
unknown: { label: 'Unknown', cls: 'badge-gray' },
|
||||
};
|
||||
|
||||
function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [fields, setFields] = useState<Record<string, string>>({});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['cluster-tools', clusterId] });
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: Record<string, string>) =>
|
||||
api.post(`/clusters/${clusterId}/tools/${tool.id}/install`, params),
|
||||
onSuccess: (res) => {
|
||||
invalidate();
|
||||
setShowForm(false);
|
||||
setFields({});
|
||||
toast.success(res.data?.message || `${tool.name} install started`);
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, `Failed to install ${tool.name}`)),
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
|
||||
onSuccess: (res) => {
|
||||
invalidate();
|
||||
toast.success(res.data?.message || `${tool.name} removed`);
|
||||
},
|
||||
onError: (err) => toast.error(apiErrorMessage(err, `Failed to remove ${tool.name}`)),
|
||||
});
|
||||
|
||||
const unmetDeps = tool.dependencies.filter(
|
||||
(depId) => tools.find((t) => t.id === depId)?.status !== 'installed',
|
||||
);
|
||||
const depsBlocked = unmetDeps.length > 0;
|
||||
const isInstalled = tool.status === 'installed';
|
||||
const isBusy = installMutation.isPending || uninstallMutation.isPending;
|
||||
const badge = TOOL_STATUS_BADGE[tool.status];
|
||||
|
||||
const startInstall = () => {
|
||||
if (tool.installFields.length > 0) {
|
||||
setShowForm((s) => !s);
|
||||
} else {
|
||||
installMutation.mutate({});
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
for (const f of tool.installFields) {
|
||||
if (f.required && !fields[f.key]?.trim()) {
|
||||
toast.error(`${f.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
installMutation.mutate(fields);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
|
||||
<span className={`badge ${badge.cls}`}>{badge.label}</span>
|
||||
<span className="badge badge-gray">{tool.category}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
|
||||
{tool.message && (
|
||||
<p className="text-xs text-gray-400 mt-1">{tool.message}</p>
|
||||
)}
|
||||
{depsBlocked && !isInstalled && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
Requires:{' '}
|
||||
{unmetDeps
|
||||
.map((d) => tools.find((t) => t.id === d)?.name || d)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{tool.status === 'installing' && (
|
||||
<RotateCw className="w-4 h-4 text-yellow-600 animate-spin" />
|
||||
)}
|
||||
{isInstalled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: `Remove ${tool.name}`,
|
||||
message: `Uninstall "${tool.name}" from this cluster?`,
|
||||
confirmText: 'Uninstall',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) uninstallMutation.mutate();
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{uninstallMutation.isPending ? 'Removing…' : 'Uninstall'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startInstall}
|
||||
disabled={isBusy || depsBlocked}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
title={depsBlocked ? 'Install required tools first' : undefined}
|
||||
>
|
||||
{installMutation.isPending
|
||||
? 'Installing…'
|
||||
: tool.status === 'failed'
|
||||
? 'Repair'
|
||||
: 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && !isInstalled && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 space-y-3 animate-fade-in">
|
||||
{tool.installFields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{f.label}{f.required ? ' *' : ''}
|
||||
</label>
|
||||
<input
|
||||
type={f.type === 'email' ? 'email' : 'text'}
|
||||
className="input-field text-sm"
|
||||
placeholder={f.placeholder}
|
||||
value={fields[f.key] || ''}
|
||||
onChange={(e) => setFields({ ...fields, [f.key]: e.target.value })}
|
||||
/>
|
||||
{f.helpText && <p className="text-xs text-gray-400 mt-1">{f.helpText}</p>}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitForm}
|
||||
disabled={installMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{installMutation.isPending ? 'Installing…' : 'Confirm install'}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterToolsPanel({
|
||||
clusters,
|
||||
selectedClusterId,
|
||||
onSelectCluster,
|
||||
}: {
|
||||
clusters: Cluster[];
|
||||
selectedClusterId: string | null;
|
||||
onSelectCluster: (id: string) => void;
|
||||
}) {
|
||||
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
||||
|
||||
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
|
||||
queryKey: ['cluster-tools', clusterId],
|
||||
queryFn: () => api.get(`/clusters/${clusterId}/tools`).then((r) => r.data),
|
||||
enabled: !!clusterId,
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data || []).some((t) => t.status === 'installing') ? 8000 : 30000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card p-4 border border-indigo-100 bg-indigo-50/30">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Install and manage infrastructure tools per cluster. Nothing is installed automatically —
|
||||
add only what each cluster needs.
|
||||
</p>
|
||||
</div>
|
||||
{clusters.length > 1 && (
|
||||
<select
|
||||
className="input-field text-sm py-1.5 max-w-[220px]"
|
||||
value={clusterId || ''}
|
||||
onChange={(e) => onSelectCluster(e.target.value)}
|
||||
>
|
||||
{clusters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}{c.isDefault ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading tools…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{clusterId &&
|
||||
tools.map((tool) => (
|
||||
<ToolRow key={tool.id} clusterId={clusterId} tool={tool} tools={tools} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminClustersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [expandedResources, setExpandedResources] = useState<Set<string>>(new Set());
|
||||
const [loggingClusterId, setLoggingClusterId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
apiServer: '',
|
||||
kubeconfig: '',
|
||||
region: '',
|
||||
provider: '',
|
||||
weight: 1,
|
||||
tags: '',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const { data: clusters = [], isLoading } = useQuery<Cluster[]>({
|
||||
queryKey: ['admin-clusters'],
|
||||
queryFn: () => api.get('/clusters').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: typeof form) => api.post('/clusters', {
|
||||
...data,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
weight: Number(data.weight) || 1,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
toast.success('Cluster added & connection verified ✓');
|
||||
setShowForm(false);
|
||||
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const message = err?.response?.data?.message || 'Failed to add cluster';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
setTestingId(id);
|
||||
return api.post(`/clusters/${id}/test`);
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
const data = res.data;
|
||||
if (data.connected) {
|
||||
toast.success(`Connection OK — Kubernetes ${data.version}`);
|
||||
} else {
|
||||
toast.error(`Connection failed: ${data.error}`);
|
||||
}
|
||||
setTestingId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to test connection');
|
||||
setTestingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||
toast.success('Cluster removed');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleResources = (id: string) => {
|
||||
setExpandedResources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Cluster Management</h1>
|
||||
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add Cluster'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{clusters.length > 0 && (
|
||||
<ClusterToolsPanel
|
||||
clusters={clusters}
|
||||
selectedClusterId={loggingClusterId}
|
||||
onSelectCluster={setLoggingClusterId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
The system will verify the Kubernetes connection before registering.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
|
||||
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
||||
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
||||
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="">Select provider</option>
|
||||
<option value="aws">AWS (EKS)</option>
|
||||
<option value="gcp">GCP (GKE)</option>
|
||||
<option value="azure">Azure (AKS)</option>
|
||||
<option value="bare-metal">Bare Metal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-field"
|
||||
value={form.weight}
|
||||
onChange={(e) => setForm({ ...form, weight: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="ssd, production, iran"
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
|
||||
<textarea
|
||||
className="input-field font-mono text-xs"
|
||||
rows={8}
|
||||
placeholder="Paste your kubeconfig here..."
|
||||
value={form.kubeconfig}
|
||||
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createMutation.mutate(form)}
|
||||
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> Verifying connection & adding...</> : 'Add Cluster'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1,2].map(i => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-64" />
|
||||
</div>
|
||||
<div className="skeleton h-8 w-24 rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : clusters.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600 font-medium">No clusters registered yet.</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div key={cluster.id} className="card">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
cluster.status === 'active' ? 'bg-emerald-50' : 'bg-red-50'
|
||||
}`}>
|
||||
{cluster.status === 'active' ? <CheckCircle className="w-5 h-5 text-emerald-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
||||
{cluster.isDefault && (
|
||||
<span className="badge badge-blue">Default</span>
|
||||
)}
|
||||
<span className={`badge ${
|
||||
cluster.status === 'active' ? 'badge-green'
|
||||
: cluster.status === 'maintenance' ? 'badge-yellow'
|
||||
: 'badge-red'
|
||||
}`}>
|
||||
{cluster.status}
|
||||
</span>
|
||||
<span className={`badge ${
|
||||
cluster.healthStatus === 'healthy' ? 'badge-green'
|
||||
: cluster.healthStatus === 'degraded' ? 'badge-yellow'
|
||||
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
|
||||
: 'badge-gray'
|
||||
}`}>
|
||||
health: {cluster.healthStatus || 'unknown'}
|
||||
</span>
|
||||
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
|
||||
</p>
|
||||
{cluster.healthMessage && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{cluster.healthMessage}
|
||||
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{cluster.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{cluster.tags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cluster.availableResources && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={() => toggleResources(cluster.id)}
|
||||
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 inline" /> Resources
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate(cluster.id)}
|
||||
disabled={testingId === cluster.id}
|
||||
className="btn-ghost text-sm disabled:opacity-50"
|
||||
>
|
||||
{testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> Testing...</> : <><Plug className="w-3 h-3 inline" /> Test</>}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await confirm({ title: 'Remove Cluster', message: `Are you sure you want to remove "${cluster.name}"?`, confirmText: 'Remove', variant: 'danger' });
|
||||
if (ok) deleteMutation.mutate(cluster.id);
|
||||
}}
|
||||
className="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable resource panel */}
|
||||
{expandedResources.has(cluster.id) && (
|
||||
<ResourcePanel clusterId={cluster.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText, Search, User, Wallet, CreditCard, XCircle, Download } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import api from '@/lib/api';
|
||||
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'Draft',
|
||||
issued: 'Unpaid',
|
||||
partially_paid: 'Partially paid',
|
||||
paid: 'Paid',
|
||||
void: 'Void',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
draft: 'badge-gray',
|
||||
issued: 'badge-yellow',
|
||||
partially_paid: 'badge-blue',
|
||||
paid: 'badge-green',
|
||||
void: 'badge-gray',
|
||||
failed: 'badge-red',
|
||||
};
|
||||
|
||||
export default function AdminInvoicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<'all' | InvoiceStatus>('all');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'all' | PaymentMethod>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['admin-invoices', status, paymentMethod, search],
|
||||
queryFn: () => {
|
||||
const params: Record<string, string> = { limit: '200' };
|
||||
if (status !== 'all') params.status = status;
|
||||
if (paymentMethod !== 'all') params.paymentMethod = paymentMethod;
|
||||
if (search.trim()) params.search = search.trim();
|
||||
return api.get('/billing/admin/invoices', { params }).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: selectedInvoice } = useQuery<Invoice>({
|
||||
queryKey: ['admin-invoice', selectedId],
|
||||
queryFn: () => api.get(`/billing/admin/invoices/${selectedId}`).then((r) => r.data),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, nextStatus, reason }: { invoiceId: string; nextStatus: InvoiceStatus; reason: string }) =>
|
||||
api.patch(`/billing/admin/invoices/${invoiceId}/status`, { status: nextStatus, reason }).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Invoice status updated');
|
||||
setStatusReason('');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invoice', selectedId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to update invoice status'),
|
||||
});
|
||||
|
||||
const downloadPdfMutation = useMutation({
|
||||
mutationFn: async (invoice: Invoice) => {
|
||||
const { data } = await api.get(`/billing/admin/invoices/${invoice.id}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invoice.invoiceNumber}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => toast.error('Failed to download invoice PDF'),
|
||||
});
|
||||
|
||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
|
||||
|
||||
const handleStatusUpdate = (nextStatus: InvoiceStatus) => {
|
||||
if (!selectedInvoice) return;
|
||||
if (!statusReason.trim()) {
|
||||
toast.error('Reason is required for manual status changes');
|
||||
return;
|
||||
}
|
||||
updateStatusMutation.mutate({
|
||||
invoiceId: selectedInvoice.id,
|
||||
nextStatus,
|
||||
reason: statusReason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Invoice Management
|
||||
</h1>
|
||||
<p className="page-subtitle">Track all user invoices, payments, gateway refs, and wallet transactions.</p>
|
||||
</div>
|
||||
|
||||
<div className="card grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="relative md:col-span-2">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search invoice, email, or application"
|
||||
className="input-field pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field">
|
||||
<option value="all">All statuses</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field">
|
||||
<option value="all">All methods</option>
|
||||
<option value="wallet">Wallet</option>
|
||||
<option value="gateway">Gateway</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-100">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Invoice</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Method</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">Loading...</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">No invoices found</td></tr>
|
||||
) : invoices.map((invoice) => (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
onClick={() => setSelectedId(invoice.id)}
|
||||
className={`cursor-pointer hover:bg-gray-50 ${selectedId === invoice.id ? 'bg-primary-50' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-xs text-gray-500">{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-900">{invoice.user?.email || invoice.userId}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{invoice.paymentMethod || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<p className="text-sm font-bold text-gray-900">{formatPrice(invoice.dueAmount)} T</p>
|
||||
<p className="text-xs text-gray-400">Total {formatPrice(invoice.total)} T</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{!selectedInvoice ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<FileText className="w-10 h-10 mx-auto mb-3" />
|
||||
Select an invoice
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadPdfMutation.mutate(selectedInvoice)}
|
||||
disabled={downloadPdfMutation.isPending}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-xs text-gray-500">Total</p>
|
||||
<p className="font-bold text-gray-900">{formatPrice(selectedInvoice.total)} T</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-xs text-gray-500">Due</p>
|
||||
<p className="font-bold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} T</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
{selectedInvoice.user?.email || selectedInvoice.userId}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<CreditCard className="w-4 h-4 text-gray-400" />
|
||||
Tracking: {selectedInvoice.gatewayTrackingCode || '-'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-700">
|
||||
<Wallet className="w-4 h-4 text-gray-400" />
|
||||
Method: {selectedInvoice.paymentMethod || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3 flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPrice(line.amount)} T</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transactions</h3>
|
||||
{(selectedInvoice.transactions || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No linked transactions yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.transactions || []).map((tx) => (
|
||||
<div key={tx.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-sm text-gray-700">{tx.description || tx.type}</span>
|
||||
<span className="text-sm font-bold">{formatPrice(tx.amount)} T</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{tx.type} · {formatDate(tx.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Manual status change</h3>
|
||||
<textarea
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
className="input-field w-full min-h-[80px]"
|
||||
placeholder="Reason is required for audit visibility"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusUpdate('failed')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="btn-secondary text-red-600 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" /> Failed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStatusUpdate('void')}
|
||||
disabled={updateStatusMutation.isPending}
|
||||
className="btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Void
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Cluster, ClusterPool } from '@/types';
|
||||
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
export default function AdminPoolsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingPool, setEditingPool] = useState<ClusterPool | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
strategy: 'weighted-resource' as 'least-apps' | 'round-robin' | 'weighted-round-robin' | 'least-loaded' | 'weighted-resource' | 'region-based',
|
||||
clusterIds: [] as string[],
|
||||
isDefault: false,
|
||||
priority: 100,
|
||||
});
|
||||
|
||||
const { data: pools = [], isLoading } = useQuery<ClusterPool[]>({
|
||||
queryKey: ['admin-pools'],
|
||||
queryFn: () => api.get('/clusters/pools').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: clusters = [] } = useQuery<Cluster[]>({
|
||||
queryKey: ['admin-clusters'],
|
||||
queryFn: () => api.get('/clusters').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: typeof form) => api.post('/clusters/pools', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||
toast.success('Cluster pool created!');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.message || 'Failed to create pool');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: typeof form }) =>
|
||||
api.patch(`/clusters/pools/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||
toast.success('Cluster pool updated!');
|
||||
resetForm();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.message || 'Failed to update pool');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/clusters/pools/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-pools'] });
|
||||
toast.success('Cluster pool deleted');
|
||||
},
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingPool(null);
|
||||
setForm({ name: '', description: '', strategy: 'weighted-resource', clusterIds: [], isDefault: false, priority: 100 });
|
||||
};
|
||||
|
||||
const startEdit = (pool: ClusterPool) => {
|
||||
setEditingPool(pool);
|
||||
setForm({
|
||||
name: pool.name,
|
||||
description: pool.description || '',
|
||||
strategy: pool.strategy,
|
||||
clusterIds: pool.clusterIds,
|
||||
isDefault: pool.isDefault || false,
|
||||
priority: pool.priority || 100,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editingPool) {
|
||||
updateMutation.mutate({ id: editingPool.id, data: form });
|
||||
} else {
|
||||
createMutation.mutate(form);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCluster = (clusterId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
clusterIds: prev.clusterIds.includes(clusterId)
|
||||
? prev.clusterIds.filter((id) => id !== clusterId)
|
||||
: [...prev.clusterIds, clusterId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Cluster Pools</h1>
|
||||
<p className="page-subtitle">
|
||||
Load-balanced groups of clusters for automatic app distribution
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { showForm ? resetForm() : setShowForm(true); }}
|
||||
className={showForm ? 'btn-ghost' : 'btn-primary'}
|
||||
>
|
||||
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Create Pool'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingPool ? `Edit "${editingPool.name}"` : 'Create New Cluster Pool'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pool Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="production-pool"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Strategy</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.strategy}
|
||||
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
|
||||
>
|
||||
<option value="weighted-resource">Weighted Resource — prefer healthy capacity and higher weights</option>
|
||||
<option value="least-loaded">Least Loaded — prefer lowest CPU, memory, and pod pressure</option>
|
||||
<option value="weighted-round-robin">Weighted Round Robin — rotate proportionally by weight</option>
|
||||
<option value="region-based">Region Based — prefer matching region, then weight and load</option>
|
||||
<option value="least-apps">Least Apps — deploy to cluster with fewest apps</option>
|
||||
<option value="round-robin">Round Robin — rotate across clusters evenly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-field"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: Number(e.target.value) || 100 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Load-balanced pool for production workloads"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isDefault}
|
||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||
/>
|
||||
Use as default allocator pool
|
||||
</label>
|
||||
|
||||
{/* Cluster selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Clusters ({form.clusterIds.length} selected)
|
||||
</label>
|
||||
{clusters.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-4 text-center">
|
||||
No clusters registered. Add clusters first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{clusters.map((cluster) => {
|
||||
const isSelected = form.clusterIds.includes(cluster.id);
|
||||
return (
|
||||
<button
|
||||
key={cluster.id}
|
||||
type="button"
|
||||
onClick={() => toggleCluster(cluster.id)}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-50 shadow-sm'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
isSelected ? 'border-primary-500 bg-primary-500' : 'border-gray-300'
|
||||
}`}>
|
||||
{isSelected && <span className="text-white text-xs">✓</span>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-gray-900">
|
||||
{cluster.name}
|
||||
{cluster.isDefault && (
|
||||
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cluster.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{cluster.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
cluster.healthStatus === 'healthy'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: cluster.healthStatus === 'degraded'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: cluster.healthStatus === 'unhealthy'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{cluster.healthStatus || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!form.name ||
|
||||
form.clusterIds.length === 0 ||
|
||||
createMutation.isPending ||
|
||||
updateMutation.isPending
|
||||
}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? <><Clock className="w-4 h-4 inline animate-spin" /> Saving...</>
|
||||
: editingPool
|
||||
? 'Update Pool'
|
||||
: 'Create Pool'}
|
||||
</button>
|
||||
<button onClick={resetForm} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pool List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1,2].map(i => (
|
||||
<div key={i} className="card space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="skeleton h-5 w-32" />
|
||||
<div className="skeleton h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="skeleton h-3 w-64" />
|
||||
<div className="flex gap-2">
|
||||
<div className="skeleton h-8 w-28 rounded-lg" />
|
||||
<div className="skeleton h-8 w-28 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pools.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Scale className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600 font-medium">No cluster pools created yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Create a pool to enable load-balanced deployment across multiple clusters
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{pools.map((pool) => {
|
||||
const poolClusters = clusters.filter((c) => pool.clusterIds.includes(c.id));
|
||||
const activeClusters = poolClusters.filter((c) => c.status === 'active');
|
||||
return (
|
||||
<div key={pool.id} className="card">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{pool.name}</h3>
|
||||
<span className={`badge ${pool.isActive ? 'badge-green' : 'badge-gray'}`}>
|
||||
{pool.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<span className="badge badge-purple flex items-center gap-1">
|
||||
{pool.strategy === 'weighted-resource'
|
||||
? <><BarChart3 className="w-3 h-3" /> Weighted Resource</>
|
||||
: pool.strategy === 'least-loaded'
|
||||
? <><BarChart3 className="w-3 h-3" /> Least Loaded</>
|
||||
: pool.strategy === 'weighted-round-robin'
|
||||
? <><RotateCw className="w-3 h-3" /> Weighted RR</>
|
||||
: pool.strategy === 'region-based'
|
||||
? <><BarChart3 className="w-3 h-3" /> Region Based</>
|
||||
: pool.strategy === 'least-apps'
|
||||
? <><BarChart3 className="w-3 h-3" /> Least Apps</>
|
||||
: <><RotateCw className="w-3 h-3" /> Round Robin</>}
|
||||
</span>
|
||||
{pool.isDefault && <span className="badge badge-blue">Default Pool</span>}
|
||||
<span className="badge badge-gray">Priority {pool.priority || 100}</span>
|
||||
</div>
|
||||
{pool.description && (
|
||||
<p className="text-sm text-gray-500 mb-3">{pool.description}</p>
|
||||
)}
|
||||
|
||||
{/* Cluster chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{poolClusters.length > 0 ? poolClusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-medium ${
|
||||
cluster.status === 'active'
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<span>{cluster.status === 'active' ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}</span>
|
||||
<span>{cluster.name}</span>
|
||||
<span className="text-gray-400">
|
||||
({cluster.provider || 'N/A'} · {cluster.region || 'N/A'} · weight {cluster.weight || 1} · {cluster.healthStatus || 'unknown'})
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-xs text-gray-400">No clusters in this pool (they may have been deleted)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
{activeClusters.length}/{poolClusters.length} clusters active · Created {new Date(pool.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => startEdit(pool)}
|
||||
className="btn-ghost text-sm"
|
||||
>
|
||||
<Pencil className="w-3 h-3 inline" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: `Delete Pool "${pool.name}"`,
|
||||
message: 'Apps already assigned to this pool will keep their current cluster.',
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(pool.id);
|
||||
}}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types';
|
||||
import { ClipboardList, Wrench, Briefcase, User, Inbox } from 'lucide-react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-blue-100 text-blue-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
const [deptFilter, setDeptFilter] = useState<TicketDepartment | ''>('');
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
|
||||
|
||||
const { data: stats } = useQuery<TicketStats>({
|
||||
queryKey: ['ticket-stats'],
|
||||
queryFn: () => api.get('/tickets/admin/stats').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
||||
queryKey: ['admin-tickets', deptFilter, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (deptFilter) params.append('department', deptFilter);
|
||||
if (statusFilter) params.append('status', statusFilter);
|
||||
const qs = params.toString();
|
||||
return api.get(`/tickets/admin/all${qs ? `?${qs}` : ''}`).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const formatResponseTime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
if (minutes < 1440) return `${Math.round(minutes / 60)} hours`;
|
||||
return `${Math.round(minutes / 1440)} days`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><ClipboardList className="w-6 h-6" /> All Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Overview of all support tickets across departments</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Total Tickets</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.totalTickets}</p>
|
||||
</div>
|
||||
<div className="card p-4 border-l-4 border-l-red-500">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${stats.openTickets > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{stats.openTickets}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Need staff response</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Avg Response Time</p>
|
||||
<p className="text-2xl font-bold text-primary-600 mt-1">
|
||||
{stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">By Department</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{Object.entries(stats.byDepartment).map(([dept, data]) => (
|
||||
<div key={dept} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 capitalize flex items-center gap-1">{dept === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {dept}</span>
|
||||
<span className="font-medium">
|
||||
<span className={data.open > 0 ? 'text-red-600' : 'text-green-600'}>{data.open} unanswered</span>
|
||||
<span className="text-gray-400"> / {data.total}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-500 self-center">Department:</span>
|
||||
{(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => (
|
||||
<button
|
||||
key={dept}
|
||||
onClick={() => setDeptFilter(dept)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
deptFilter === dept
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{dept === '' ? 'All' : dept === 'technical' ? 'Technical' : 'Sales'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-500 self-center">Status:</span>
|
||||
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === status
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Inbox className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets found</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/tickets/${ticket.id}`}
|
||||
className="card p-4 block hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 flex items-center gap-1">
|
||||
{ticket.department === 'technical' ? <Wrench className="w-3 h-3" /> : <Briefcase className="w-3 h-3" />} {ticket.department === 'technical' ? 'Technical' : 'Sales'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
{ticket.user && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
|
||||
</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { AdminUser } from '@/types';
|
||||
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const canStaffResetPassword =
|
||||
currentUser?.role === 'admin' || currentUser?.role === 'technical';
|
||||
const canResetPasswordFor = (user: AdminUser) => {
|
||||
if (!canStaffResetPassword) return false;
|
||||
if (currentUser?.role === 'admin') return true;
|
||||
return user.role === 'user';
|
||||
};
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
|
||||
const [pwdModalPassword, setPwdModalPassword] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
|
||||
});
|
||||
|
||||
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
|
||||
queryKey: ['admin-users', search],
|
||||
queryFn: () =>
|
||||
api.get('/users', { params: search ? { search } : {} }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const createUser = useMutation({
|
||||
mutationFn: (data: typeof form) => api.post('/users', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('User created successfully');
|
||||
setShowForm(false);
|
||||
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.message || 'Failed to create user');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActive = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('User updated');
|
||||
},
|
||||
});
|
||||
|
||||
const changeRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||
api.patch(`/users/${id}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('Role updated');
|
||||
},
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: ({ id, password }: { id: string; password: string }) =>
|
||||
api.patch(`/users/${id}/password`, { password }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('Password updated');
|
||||
setPwdModalUser(null);
|
||||
setPwdModalPassword('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: undefined;
|
||||
toast.error(typeof msg === 'string' ? msg : 'Failed to update password');
|
||||
},
|
||||
});
|
||||
|
||||
const openPwdModal = (user: AdminUser) => {
|
||||
setPwdModalUser(user);
|
||||
setPwdModalPassword('');
|
||||
};
|
||||
|
||||
const closePwdModal = () => {
|
||||
if (resetPassword.isPending) return;
|
||||
setPwdModalUser(null);
|
||||
setPwdModalPassword('');
|
||||
};
|
||||
|
||||
const submitPwdModal = () => {
|
||||
if (!pwdModalUser || pwdModalPassword.length < 8) return;
|
||||
resetPassword.mutate({ id: pwdModalUser.id, password: pwdModalPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">User Management</h1>
|
||||
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create user form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New User</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="John"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Doe"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="password"
|
||||
placeholder="Min 8 characters"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<select
|
||||
className="input-field w-full sm:w-48"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createUser.mutate(form)}
|
||||
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Creating...</> : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
className="input-field pl-10 w-full"
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-10 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Users className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden lg:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</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">Apps</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</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">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`badge ${
|
||||
user.role === 'admin' ? 'badge-purple' :
|
||||
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
'badge-gray'
|
||||
}`}>{user.role}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge badge-blue">{user.appCount ?? 0}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{canResetPasswordFor(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPwdModal(user)}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" /> Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="lg:hidden grid gap-3">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
|
||||
<span className={`badge ${
|
||||
user.role === 'admin' ? 'badge-purple' :
|
||||
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
'badge-gray'
|
||||
}`}>{user.role}</span>
|
||||
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
|
||||
)}
|
||||
{canResetPasswordFor(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPwdModal(user)}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" /> Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pwdModalUser && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="presentation"
|
||||
onClick={(e) => e.target === e.currentTarget && closePwdModal()}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Set password</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
New password for{' '}
|
||||
<strong>
|
||||
{pwdModalUser.firstName} {pwdModalUser.lastName}
|
||||
</strong>{' '}
|
||||
({pwdModalUser.email})
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field w-full"
|
||||
placeholder="Min 8 characters"
|
||||
value={pwdModalPassword}
|
||||
onChange={(e) => setPwdModalPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" className="btn-ghost" onClick={closePwdModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
disabled={pwdModalPassword.length < 8 || resetPassword.isPending}
|
||||
onClick={submitPwdModal}
|
||||
>
|
||||
{resetPassword.isPending ? 'Saving...' : 'Save password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
||||
import {
|
||||
DeletingTableRowOverlay,
|
||||
deletingResourceMessage,
|
||||
deletingRowContentClass,
|
||||
} from '@/components/deleting-overlay';
|
||||
import { filterApplications } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
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 confirm = useConfirm();
|
||||
|
||||
const { data: appsRaw = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'application'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
||||
});
|
||||
const apps = filterApplications(appsRaw);
|
||||
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'application'], ['applications']],
|
||||
successMessage: 'Application deleted',
|
||||
successWithCreditMessage: 'Application deleted. Your prepaid resources are shown on the dashboard.',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-48" />
|
||||
<div className="skeleton h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-56" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">My Applications</h1>
|
||||
<p className="page-subtitle">{apps.length} application{apps.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<Link href="/dashboard/deploy" className="btn-primary">
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New Application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">Deploy your first application to get started.</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<Rocket className="w-4 h-4" /> Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden md:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider min-w-[300px]">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">Status</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);
|
||||
const rowDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<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' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
|
||||
>
|
||||
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group min-w-[300px]">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>{app.runtime}</td>
|
||||
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<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 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
{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 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<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">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
message: `Permanently delete "${app.name}" and all its data?\n\nIf your plan still has time left, the prepaid resources will appear on your dashboard for use on a new app at no extra charge.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{rowDeleting && (
|
||||
<DeletingTableRowOverlay
|
||||
colSpan={6}
|
||||
message={deletingResourceMessage('application', app.name)}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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 ${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">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{app.name}</h3>
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCard, FileText, Wallet, XCircle, CheckCircle, Clock, Download } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import api from '@/lib/api';
|
||||
import type { Invoice, InvoiceStatus } from '@/types';
|
||||
|
||||
const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'Draft',
|
||||
issued: 'Unpaid',
|
||||
partially_paid: 'Partially paid',
|
||||
paid: 'Paid',
|
||||
void: 'Void',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
draft: 'badge-gray',
|
||||
issued: 'badge-yellow',
|
||||
partially_paid: 'badge-blue',
|
||||
paid: 'badge-green',
|
||||
void: 'badge-gray',
|
||||
failed: 'badge-red',
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unpaid' | InvoiceStatus>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('invoice'));
|
||||
|
||||
useEffect(() => {
|
||||
const invoiceId = searchParams.get('invoice');
|
||||
if (invoiceId) setSelectedId(invoiceId);
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices', statusFilter],
|
||||
queryFn: () => {
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter !== 'all' && statusFilter !== 'unpaid') params.status = statusFilter;
|
||||
return api.get('/billing/invoices', { params }).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const visibleInvoices = invoices.filter((invoice) => {
|
||||
if (statusFilter === 'unpaid') return invoice.status === 'issued' || invoice.status === 'partially_paid';
|
||||
return true;
|
||||
});
|
||||
|
||||
const { data: selectedInvoice } = useQuery<Invoice>({
|
||||
queryKey: ['invoice', selectedId],
|
||||
queryFn: () => api.get(`/billing/invoices/${selectedId}`).then((r) => r.data),
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoice', selectedId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
};
|
||||
|
||||
const verifyGatewayMutation = useMutation({
|
||||
mutationFn: ({ invoiceId, trackingCode, amount }: { invoiceId: string; trackingCode: string; amount: number }) =>
|
||||
api.post(`/billing/invoices/${invoiceId}/gateway/verify`, { trackingCode, amount }).then((r) => r.data),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.effect ? 'Payment complete and service updated' : 'Payment complete');
|
||||
refresh();
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Gateway payment failed'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const invoiceId = searchParams.get('invoiceId');
|
||||
const trackingCode = searchParams.get('trackingCode');
|
||||
const amount = Number(searchParams.get('amount') || 0);
|
||||
const status = searchParams.get('status');
|
||||
if (invoiceId && trackingCode && amount > 0 && status === 'success' && !verifyGatewayMutation.isPending) {
|
||||
setSelectedId(invoiceId);
|
||||
verifyGatewayMutation.mutate({ invoiceId, trackingCode, amount });
|
||||
}
|
||||
}, [searchParams, verifyGatewayMutation]);
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: async (invoiceId: string) => {
|
||||
const callbackUrl = `${window.location.origin}/dashboard/invoices`;
|
||||
const { data } = await api.post(`/billing/invoices/${invoiceId}/pay/mixed`, { callbackUrl });
|
||||
if (data.gatewayUrl && data.gatewayAmount > 0) {
|
||||
window.location.href = data.gatewayUrl;
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.gatewayUrl && data.gatewayAmount > 0) return;
|
||||
toast.success(data.effect ? 'Invoice paid and service updated' : 'Invoice paid');
|
||||
refresh();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
|
||||
});
|
||||
|
||||
const downloadPdfMutation = useMutation({
|
||||
mutationFn: async (invoice: Invoice) => {
|
||||
const { data } = await api.get(`/billing/invoices/${invoice.id}/pdf`, { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([data], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${invoice.invoiceNumber}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => toast.error('Failed to download invoice PDF'),
|
||||
});
|
||||
|
||||
const formatPrice = (amount: number) => Number(amount || 0).toLocaleString('en-US');
|
||||
const formatDate = (value?: string) => value ? new Date(value).toLocaleString('en-US') : '-';
|
||||
const dueAmount = Number(selectedInvoice?.dueAmount || 0);
|
||||
const walletBalance = Number(walletData?.balance || 0);
|
||||
const isPayable = selectedInvoice?.status === 'issued' || selectedInvoice?.status === 'partially_paid';
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Invoices
|
||||
</h1>
|
||||
<p className="page-subtitle">Review what each payment was for and pay open invoices.</p>
|
||||
</div>
|
||||
<div className="card py-3 px-4 flex items-center gap-3">
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Wallet balance</p>
|
||||
<p className="font-bold text-gray-900">{formatPrice(walletBalance)} Toman</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['all', 'unpaid', 'paid', 'failed', 'void'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border ${
|
||||
statusFilter === status
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'unpaid' ? 'Unpaid' : statusLabels[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className="lg:col-span-3 card p-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading invoices...</div>
|
||||
) : visibleInvoices.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">No invoices found</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{visibleInvoices.map((invoice) => (
|
||||
<button
|
||||
key={invoice.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(invoice.id)}
|
||||
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
|
||||
selectedId === invoice.id ? 'bg-primary-50' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.application?.name || invoice.reason} · {formatDate(invoice.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={statusClasses[invoice.status]}>{statusLabels[invoice.status]}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">Total {formatPrice(invoice.total)} Toman</span>
|
||||
<span className="font-semibold text-gray-900">Due {formatPrice(invoice.dueAmount)} Toman</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 card">
|
||||
{!selectedInvoice ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<FileText className="w-10 h-10 mx-auto mb-3" />
|
||||
Select an invoice to view details
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{selectedInvoice.invoiceNumber}</h2>
|
||||
<p className="text-sm text-gray-500">{selectedInvoice.application?.name || selectedInvoice.reason}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusClasses[selectedInvoice.status]}>{statusLabels[selectedInvoice.status]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadPdfMutation.mutate(selectedInvoice)}
|
||||
disabled={downloadPdfMutation.isPending}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Total</span><span className="font-semibold">{formatPrice(selectedInvoice.total)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Paid</span><span className="font-semibold text-green-600">{formatPrice(selectedInvoice.paidAmount)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Due</span><span className="font-semibold text-primary-700">{formatPrice(selectedInvoice.dueAmount)} Toman</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Method</span><span>{selectedInvoice.paymentMethod || '-'}</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Line items</h3>
|
||||
<div className="space-y-2">
|
||||
{(selectedInvoice.lines || []).map((line) => (
|
||||
<div key={line.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{line.label}</p>
|
||||
{line.description && <p className="text-xs text-gray-500">{line.description}</p>}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900">{formatPrice(line.amount)} T</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPayable && (
|
||||
<div className="space-y-3 border-t border-gray-100 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Payment</h3>
|
||||
{walletBalance < dueAmount && (
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-2">
|
||||
Wallet balance covers {formatPrice(walletBalance)} Toman. The remaining {formatPrice(dueAmount - walletBalance)} Toman will be paid through the gateway.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => payMutation.mutate(selectedInvoice.id)}
|
||||
disabled={payMutation.isPending || verifyGatewayMutation.isPending}
|
||||
className="w-full btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{payMutation.isPending || verifyGatewayMutation.isPending ? 'در حال پردازش...' : 'پرداخت'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedInvoice.status === 'paid' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-100 rounded-xl p-3">
|
||||
<CheckCircle className="w-4 h-4" /> Paid on {formatDate(selectedInvoice.paidAt)}
|
||||
</div>
|
||||
)}
|
||||
{selectedInvoice.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||
<XCircle className="w-4 h-4" /> {selectedInvoice.statusReason || 'Payment failed'}
|
||||
</div>
|
||||
)}
|
||||
{selectedInvoice.status === 'partially_paid' && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700 bg-blue-50 border border-blue-100 rounded-xl p-3">
|
||||
<Clock className="w-4 h-4" /> Waiting for the remaining payment.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import api from '@/lib/api';
|
||||
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Rocket,
|
||||
Ticket,
|
||||
Users,
|
||||
Server,
|
||||
Scale,
|
||||
ClipboardList,
|
||||
Wrench,
|
||||
Briefcase,
|
||||
Cloud,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
Boxes,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ScrollText,
|
||||
FileText,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
||||
|
||||
const userNavItems: NavItem[] = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/services', label: 'Databases & Services', icon: <Database className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/logs', label: 'Logs', icon: <ScrollText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/billing', label: 'Billing Plans', icon: <CreditCard className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/invoices', label: 'Invoices', icon: <FileText className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const technicalNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/staff/tickets', label: 'Technical Tickets', icon: <Wrench className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const salesNavItems: NavItem[] = [
|
||||
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ href: '/dashboard/staff/tickets', label: 'Sales Tickets', icon: <Briefcase className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const deployBarMinimized = useDeployProgressStore((s) => s.minimized);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Fetch unanswered ticket counts for staff/admin roles
|
||||
// Must be called before any early returns to respect React's rules of hooks
|
||||
const isStaffOrAdmin = user?.role === 'admin' || user?.role === 'technical' || user?.role === 'sales';
|
||||
const { data: unansweredCounts } = useQuery<{ technical: number; sales: number; total: number }>({
|
||||
queryKey: ['unanswered-counts'],
|
||||
queryFn: () => api.get('/tickets/unanswered-counts').then((r) => r.data),
|
||||
enabled: isStaffOrAdmin && isAuthenticated,
|
||||
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">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-primary-600 border-t-transparent" />
|
||||
<span className="text-sm text-gray-500">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
|
||||
const NavLink = ({ item, badge }: { item: NavItem; badge?: number }) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href + '/'));
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="min-w-[20px] h-5 flex items-center justify-center px-1.5 text-xs font-bold rounded-full bg-red-500 text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const getBadge = (href: string): number | undefined => {
|
||||
if (!unansweredCounts) return undefined;
|
||||
if (href === '/dashboard/staff/tickets') {
|
||||
// Staff ticket page: show count for their department
|
||||
if (user?.role === 'technical') return unansweredCounts.technical;
|
||||
if (user?.role === 'sales') return unansweredCounts.sales;
|
||||
}
|
||||
if (href === '/dashboard/admin/tickets') {
|
||||
return unansweredCounts.total;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const SidebarContent = () => (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="space-y-1 flex-1">
|
||||
{userNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
||||
))}
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="pt-5 pb-2">
|
||||
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Admin
|
||||
</p>
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user?.role === 'technical' && (
|
||||
<>
|
||||
<div className="pt-5 pb-2">
|
||||
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Technical
|
||||
</p>
|
||||
</div>
|
||||
{technicalNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user?.role === 'sales' && (
|
||||
<>
|
||||
<div className="pt-5 pb-2">
|
||||
<p className="px-3 text-[11px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Sales
|
||||
</p>
|
||||
</div>
|
||||
{salesNavItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} badge={getBadge(item.href)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
<div className="pt-4 mt-4 border-t border-gray-200">
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs font-semibold text-gray-700 truncate">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<DeploymentProgressManager />
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 p-4 transform transition-transform duration-200 ease-in-out lg:hidden ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Link href="/dashboard" className="text-lg font-bold text-primary-600">
|
||||
<Cloud className="w-5 h-5 inline mr-1" /> CloudHost
|
||||
</Link>
|
||||
<button onClick={() => setSidebarOpen(false)} className="btn-icon">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Header */}
|
||||
<header
|
||||
className={`bg-white/80 backdrop-blur-lg border-b border-gray-200/80 sticky z-30 ${
|
||||
deployBarMinimized ? 'top-11' : 'top-0'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="btn-icon lg:hidden"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<Link href="/dashboard" className="text-xl font-bold text-primary-600 flex items-center gap-2">
|
||||
<Cloud className="w-6 h-6" />
|
||||
<span className="hidden sm:inline">CloudHost</span>
|
||||
</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}
|
||||
</span>
|
||||
{user?.role === 'admin' && (
|
||||
<span className="badge-purple">Admin</span>
|
||||
)}
|
||||
{user?.role === 'technical' && (
|
||||
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-700">Technical</span>
|
||||
)}
|
||||
{user?.role === 'sales' && (
|
||||
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-700">Sales</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { logout(); router.push('/login'); }}
|
||||
className="btn-ghost text-gray-500 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4 sm:hidden" />
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Desktop sidebar */}
|
||||
<nav className="hidden lg:block w-56 flex-shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 animate-fade-in">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LOG_LEVELS = [
|
||||
{ value: '', label: 'All levels' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'warn', label: 'Warning' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'debug', label: 'Debug' },
|
||||
];
|
||||
|
||||
const WORKLOADS = [
|
||||
{ value: '', label: 'All sources' },
|
||||
{ value: 'app', label: 'Application' },
|
||||
{ value: 'redis', label: 'Redis' },
|
||||
{ value: 'rabbitmq', label: 'RabbitMQ' },
|
||||
{ value: 'database', label: 'Database' },
|
||||
];
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ value: '1h', label: 'Last hour' },
|
||||
{ value: '6h', label: 'Last 6 hours' },
|
||||
{ value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
];
|
||||
|
||||
function levelBadgeClass(level: string) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
return 'bg-amber-100 text-amber-800';
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
}
|
||||
|
||||
function LogsPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialAppId = searchParams.get('appId') || '';
|
||||
|
||||
const [appId, setAppId] = useState(initialAppId);
|
||||
const [workload, setWorkload] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [timeRange, setTimeRange] = useState('24h');
|
||||
const [page, setPage] = useState(1);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAppId) setAppId(initialAppId);
|
||||
}, [initialAppId]);
|
||||
|
||||
const { data: loggingStatus, isFetching: statusFetching } = useQuery({
|
||||
queryKey: ['logs-status', appId],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get('/logs/status', { params: appId ? { appId } : undefined })
|
||||
.then((r) =>
|
||||
r.data as {
|
||||
available: boolean;
|
||||
deployed?: boolean;
|
||||
recovering?: boolean;
|
||||
message?: string;
|
||||
},
|
||||
),
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data;
|
||||
if (s?.available) return false;
|
||||
if (s?.deployed === false) return false;
|
||||
return 8_000;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: applications = [] } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'application'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: managedServices = [] } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'managed'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const selectedManaged = useMemo(
|
||||
() => managedServices.find((s) => s.id === appId),
|
||||
[managedServices, appId],
|
||||
);
|
||||
|
||||
const workloadOptions = useMemo(() => {
|
||||
if (!appId || !selectedManaged) {
|
||||
return WORKLOADS;
|
||||
}
|
||||
const opts: { value: string; label: string }[] = [{ value: '', label: 'All sources' }];
|
||||
if (selectedManaged.productType === 'managed_database') {
|
||||
opts.push({ value: 'database', label: 'Database' });
|
||||
} else if (selectedManaged.productType === 'managed_redis') {
|
||||
opts.push({ value: 'redis', label: 'Redis' });
|
||||
} else if (selectedManaged.productType === 'managed_rabbitmq') {
|
||||
opts.push({ value: 'rabbitmq', label: 'RabbitMQ' });
|
||||
}
|
||||
return opts;
|
||||
}, [appId, selectedManaged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedManaged) return;
|
||||
if (workload === 'app') {
|
||||
setWorkload('');
|
||||
}
|
||||
}, [selectedManaged, workload]);
|
||||
|
||||
const buildTimeRange = () => {
|
||||
const now = new Date();
|
||||
const from = new Date();
|
||||
switch (timeRange) {
|
||||
case '1h':
|
||||
from.setHours(now.getHours() - 1);
|
||||
break;
|
||||
case '6h':
|
||||
from.setHours(now.getHours() - 6);
|
||||
break;
|
||||
case '7d':
|
||||
from.setDate(now.getDate() - 7);
|
||||
break;
|
||||
default:
|
||||
from.setDate(now.getDate() - 1);
|
||||
}
|
||||
return { from: from.toISOString(), to: now.toISOString() };
|
||||
};
|
||||
|
||||
const { from, to } = buildTimeRange();
|
||||
|
||||
const {
|
||||
data: logsResult,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery<LogSearchResult>({
|
||||
queryKey: ['logs', appId, workload, level, search, timeRange, page],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (appId) params.set('appId', appId);
|
||||
if (workload) params.set('workload', workload);
|
||||
if (level) params.set('level', level);
|
||||
if (search) params.set('search', search);
|
||||
params.set('from', from);
|
||||
params.set('to', to);
|
||||
params.set('page', String(page));
|
||||
params.set('limit', '100');
|
||||
return api.get(`/logs?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available === true,
|
||||
refetchInterval: autoRefresh ? 5000 : false,
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery<LogStatsResult>({
|
||||
queryKey: ['log-stats', appId, workload, timeRange],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (appId) params.set('appId', appId);
|
||||
if (workload) params.set('workload', workload);
|
||||
params.set('period', timeRange);
|
||||
return api.get(`/logs/stats?${params.toString()}`).then((r) => r.data);
|
||||
},
|
||||
enabled: loggingStatus?.available === true,
|
||||
});
|
||||
|
||||
const totalPages = logsResult ? Math.max(1, Math.ceil(logsResult.total / logsResult.limit)) : 1;
|
||||
|
||||
if (loggingStatus && !loggingStatus.available) {
|
||||
const isRecovering = loggingStatus.recovering === true;
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto card p-8 text-center">
|
||||
{isRecovering ? (
|
||||
<Loader2 className="w-12 h-12 text-primary-500 mx-auto mb-4 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
)}
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">
|
||||
{isRecovering ? 'Reconnecting to logging…' : 'Logging not available'}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap">
|
||||
{loggingStatus.message ||
|
||||
'Central Elasticsearch is not deployed on the cluster. Enable the logging addon when deploying an app, and ask an administrator to deploy the logging stack.'}
|
||||
</p>
|
||||
{isRecovering && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{statusFetching ? 'Checking connection…' : 'Retrying automatically every few seconds.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" /> Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Application, Redis, RabbitMQ, and database logs in one place
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Resource</label>
|
||||
<select
|
||||
value={appId}
|
||||
onChange={(e) => {
|
||||
setAppId(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">All resources</option>
|
||||
{applications.length > 0 && (
|
||||
<optgroup label="Applications">
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{managedServices.length > 0 && (
|
||||
<optgroup label="Databases & services">
|
||||
{managedServices.map((svc) => (
|
||||
<option key={svc.id} value={svc.id}>
|
||||
{svc.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Source</label>
|
||||
<select
|
||||
value={workload}
|
||||
onChange={(e) => {
|
||||
setWorkload(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{workloadOptions.map((w) => (
|
||||
<option key={w.value || 'all'} value={w.value}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Level</label>
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => {
|
||||
setLevel(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l.value || 'all'} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Time range</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => {
|
||||
setTimeRange(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{TIME_RANGES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search message..."
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-4">
|
||||
<button type="button" onClick={() => refetch()} disabled={isFetching} className="btn-primary text-sm">
|
||||
{isFetching ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 inline animate-spin mr-1" /> Loading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" /> Refresh
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Auto-refresh (5s)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500">Total ({stats.period})</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<div className="card p-4 border-red-100">
|
||||
<p className="text-xs text-red-600">Errors</p>
|
||||
<p className="text-2xl font-bold text-red-700">{stats.errors}</p>
|
||||
</div>
|
||||
<div className="card p-4 border-amber-100">
|
||||
<p className="text-xs text-amber-600">Warnings</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{stats.warnings}</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500">Sources</p>
|
||||
<p className="text-sm font-mono text-gray-800 mt-1">
|
||||
{Object.entries(stats.byWorkload || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(' · ') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="card p-4 border-red-200 bg-red-50 text-red-800 text-sm">
|
||||
Failed to load logs. Ensure logging is enabled on your application and Elasticsearch is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Log entries</h2>
|
||||
{logsResult && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{logsResult.total} total · page {page}/{totalPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2" />
|
||||
Loading logs...
|
||||
</div>
|
||||
) : !logsResult?.hits?.length ? (
|
||||
<div className="p-12 text-center text-gray-500 text-sm">
|
||||
No logs found for the selected filters.
|
||||
{!appId && <p className="mt-2">Deploy an app with logging enabled to start collecting logs.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Time</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Level</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 min-w-[300px]">App</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Source</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{logsResult.hits.map((entry: LogEntry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 align-top">
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-600 whitespace-nowrap">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${levelBadgeClass(entry.level)}`}>
|
||||
{entry.level?.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-800 min-w-[300px]">{entry.applicationName || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-600 capitalize">{entry.workload || 'app'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800 break-all max-w-xl">
|
||||
{entry.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logsResult && logsResult.total > logsResult.limit && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="btn-secondary text-sm disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 inline" /> Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="btn-secondary text-sm disabled:opacity-40"
|
||||
>
|
||||
Next <ChevronRight className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-center text-gray-500">Loading...</div>}>
|
||||
<LogsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react';
|
||||
import type { ResourceCredit } from '@/types';
|
||||
import { formatExpiresAtLocal } from '@/lib/format-utils';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import { filterApplications, filterManagedServices } from '@/lib/product-type';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'bg-emerald-100 text-emerald-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
building: 'bg-blue-100 text-blue-700',
|
||||
deploying: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
build_failed: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-gray-100 text-gray-600',
|
||||
stopped: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
const statusIcons: Record<string, ReactNode> = {
|
||||
running: <Circle className="w-3 h-3 fill-emerald-500 text-emerald-500" />,
|
||||
pending: <Circle className="w-3 h-3 fill-amber-500 text-amber-500" />,
|
||||
building: <Circle className="w-3 h-3 fill-blue-500 text-blue-500" />,
|
||||
deploying: <Circle className="w-3 h-3 fill-blue-500 text-blue-500" />,
|
||||
failed: <Circle className="w-3 h-3 fill-red-500 text-red-500" />,
|
||||
build_failed: <Circle className="w-3 h-3 fill-red-500 text-red-500" />,
|
||||
cancelled: <Circle className="w-3 h-3 fill-gray-400 text-gray-400" />,
|
||||
stopped: <Circle className="w-3 h-3 fill-gray-400 text-gray-400" />,
|
||||
};
|
||||
|
||||
function countRunning(apps: Application[]) {
|
||||
return apps.filter((a) => a.deployments?.some((d) => d.status === 'running')).length;
|
||||
}
|
||||
|
||||
function countFailed(apps: Application[]) {
|
||||
return apps.filter((a) =>
|
||||
a.deployments?.some((d) => d.status === 'failed' || d.status === 'build_failed'),
|
||||
).length;
|
||||
}
|
||||
|
||||
function serviceSubtitle(app: Application): string {
|
||||
if (app.productType === 'managed_database') {
|
||||
return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_redis') {
|
||||
return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_rabbitmq') {
|
||||
return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data: appsRaw = [], isLoading: appsLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'application'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: servicesRaw = [], isLoading: servicesLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'managed'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const apps = filterApplications(appsRaw);
|
||||
const services = filterManagedServices(servicesRaw);
|
||||
const isLoading = appsLoading || servicesLoading;
|
||||
|
||||
const { data: resourceCredits = [] } = useQuery<ResourceCredit[]>({
|
||||
queryKey: ['resource-credits'],
|
||||
queryFn: () => api.get('/billing/resource-credits').then((r) => r.data),
|
||||
});
|
||||
|
||||
const runningApps = countRunning(apps);
|
||||
const failedApps = countFailed(apps);
|
||||
const runningServices = countRunning(services);
|
||||
const failedServices = countFailed(services);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="page-title">Welcome back, {user?.firstName}!</h1>
|
||||
<p className="page-subtitle">Overview of your applications and managed services.</p>
|
||||
</div>
|
||||
|
||||
{resourceCredits.length > 0 && (
|
||||
<div className="card border-2 border-indigo-100 bg-indigo-50/40 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wallet className="w-5 h-5 text-indigo-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-indigo-900">Prepaid resource credits</h2>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
If you delete an app before your plan ends, you can deploy a new app with the same resources at no
|
||||
extra charge until the credit expires.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{resourceCredits.map((credit) => (
|
||||
<div key={credit.id} className="rounded-xl border border-indigo-200 bg-white p-4 text-sm">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{credit.sourceAppName ? `From app “${credit.sourceAppName}”` : 'Resource credit'}
|
||||
</p>
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-indigo-700 font-medium">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{credit.remainingLabel} remaining
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Expires {formatExpiresAtLocal(credit.expiresAt)}
|
||||
</p>
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-600">
|
||||
<li>CPU: {credit.cpuLimit}</li>
|
||||
<li>RAM: {credit.memoryLimit}</li>
|
||||
<li>Replicas: {credit.replicas}</li>
|
||||
<li>DB: {credit.databaseType}</li>
|
||||
<li>DB disk: {credit.dbStorageSize}</li>
|
||||
<li>App disk: {credit.appStorageSize}</li>
|
||||
{credit.enableRedis && <li>Redis</li>}
|
||||
{credit.enableRabbitmq && <li>RabbitMQ</li>}
|
||||
{credit.enableElasticsearch && <li>Elasticsearch</li>}
|
||||
</ul>
|
||||
<Link href="/dashboard/deploy" className="mt-3 inline-block text-xs font-medium text-primary-600 hover:underline">
|
||||
Use on new app →
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Applications</div>
|
||||
<div className="stat-value text-gray-900">{isLoading ? '—' : apps.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Apps running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningApps}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Managed services</div>
|
||||
<div className="stat-value text-gray-900">{isLoading ? '—' : services.length}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Services running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningServices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(failedApps > 0 || failedServices > 0) && (
|
||||
<div className="text-sm text-red-600 font-medium">
|
||||
{failedApps > 0 && `${failedApps} application${failedApps !== 1 ? 's' : ''} failed`}
|
||||
{failedApps > 0 && failedServices > 0 && ' · '}
|
||||
{failedServices > 0 && `${failedServices} service${failedServices !== 1 ? 's' : ''} failed`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications */}
|
||||
<div>
|
||||
<div className="page-header mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent applications</h2>
|
||||
<Link href="/dashboard/deploy" className="btn-primary text-sm">
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{appsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Package className="w-10 h-10 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-gray-600 font-medium">No applications yet</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-4 inline-flex items-center gap-1 text-sm">
|
||||
<Rocket className="w-4 h-4" /> Deploy your first app
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{apps.slice(0, 5).map((app) => {
|
||||
const latestDeploy = app.deployments?.[0];
|
||||
const status = latestDeploy?.status || 'pending';
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
|
||||
<Hexagon
|
||||
className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 truncate">{app.name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime} · {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
{app.databaseType !== 'none' && ` · ${app.databaseType}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusIcons[status]}
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>{status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{apps.length > 5 && (
|
||||
<Link href="/dashboard/apps" className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-2">
|
||||
View all {apps.length} applications →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Managed services */}
|
||||
<div>
|
||||
<div className="page-header mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent databases & services</h2>
|
||||
<Link href="/dashboard/services/new" className="btn-primary text-sm">
|
||||
<Plus className="w-4 h-4 mr-1 inline" /> New service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servicesLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : services.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-gray-600 font-medium">No managed services yet</p>
|
||||
<Link href="/dashboard/services/new" className="btn-primary mt-4 inline-flex items-center gap-1 text-sm">
|
||||
<Plus className="w-4 h-4" /> Create database or service
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{services.slice(0, 5).map((svc) => {
|
||||
const latestDeploy = svc.deployments?.[0];
|
||||
const status = latestDeploy?.status || 'pending';
|
||||
return (
|
||||
<Link
|
||||
key={svc.id}
|
||||
href={`/dashboard/services/${svc.id}`}
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary-700 truncate">{svc.name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusIcons[status]}
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>{status}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{services.length > 5 && (
|
||||
<Link
|
||||
href="/dashboard/services"
|
||||
className="text-center text-sm text-primary-600 hover:text-primary-700 font-medium py-2"
|
||||
>
|
||||
View all {services.length} services →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type {
|
||||
Application,
|
||||
Deployment,
|
||||
OptionalServiceCredentials,
|
||||
OptionalServiceResourcesMap,
|
||||
} from '@/types';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Rocket,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
KeyRound,
|
||||
RotateCw,
|
||||
Package,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeletingModal } from '@/components/deleting-modal';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
|
||||
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
||||
import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel';
|
||||
import { DatabaseSnapshotsPanel } from '@/components/database-snapshots-panel';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
pending: 'badge-yellow',
|
||||
building: 'badge-blue',
|
||||
deploying: 'badge-blue',
|
||||
failed: 'badge-red',
|
||||
build_failed: 'badge-red',
|
||||
cancelled: 'badge-gray',
|
||||
stopped: 'badge-gray',
|
||||
};
|
||||
|
||||
function dbPort(databaseType: string): string {
|
||||
if (databaseType === 'postgresql') return '5432';
|
||||
if (databaseType === 'mongodb') return '27017';
|
||||
return '3306';
|
||||
}
|
||||
|
||||
type AppWithOptional = Application & { optionalServiceResources?: OptionalServiceResourcesMap };
|
||||
|
||||
export default function ManagedServiceDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { notifyDeployStarted } = useDeployProgressActions();
|
||||
const confirm = useConfirm();
|
||||
const serviceId = params.id as string;
|
||||
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [showRenewalModal, setShowRenewalModal] = useState(false);
|
||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
|
||||
|
||||
const { data: app, isLoading } = useQuery<AppWithOptional>({
|
||||
queryKey: ['application', serviceId],
|
||||
queryFn: () => api.get(`/applications/${serviceId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: deployments = [] } = useQuery<Deployment[]>({
|
||||
queryKey: ['deployments', serviceId],
|
||||
queryFn: () => api.get(`/deployments/applications/${serviceId}`).then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: serviceCredentials } = useQuery<OptionalServiceCredentials>({
|
||||
queryKey: ['service-credentials', serviceId],
|
||||
queryFn: () => api.get(`/applications/${serviceId}/service-credentials`).then((r) => r.data),
|
||||
enabled:
|
||||
!!app &&
|
||||
(app.productType === 'managed_redis' ||
|
||||
app.productType === 'managed_rabbitmq' ||
|
||||
!!app.enableRedis ||
|
||||
!!app.enableRabbitmq),
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: renewalCostData } = useQuery<{
|
||||
costs: { hourly: number; monthly: number; yearly: number };
|
||||
}>({
|
||||
queryKey: ['renewal-cost', serviceId],
|
||||
queryFn: () => api.get(`/billing/applications/${serviceId}/renewal-cost`).then((r) => r.data),
|
||||
enabled:
|
||||
showRenewalModal ||
|
||||
app?.lifecycleStatus === 'suspended' ||
|
||||
app?.lifecycleStatus === 'pending_deletion',
|
||||
});
|
||||
|
||||
const needsRenewal =
|
||||
app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/deploy`),
|
||||
onMutate: () => {
|
||||
notifyDeployStarted(serviceId, app?.name);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Provisioning started');
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
},
|
||||
onError: () => {
|
||||
useDeployProgressStore.getState().stopTracking(serviceId);
|
||||
toast.error('Failed to start provisioning');
|
||||
},
|
||||
});
|
||||
|
||||
const redeployMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`),
|
||||
onMutate: () => {
|
||||
notifyDeployStarted(serviceId, app?.name);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Re-provisioning started');
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
useDeployProgressStore.getState().stopTracking(serviceId);
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to re-provision service');
|
||||
},
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/restart`),
|
||||
onSuccess: () => toast.success('Service restarted'),
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to restart service');
|
||||
},
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: (cycle: string) => api.post(`/billing/applications/${serviceId}/renew`, { cycle }),
|
||||
onSuccess: () => {
|
||||
toast.success('Service renewed');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
setShowRenewalModal(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Renewal failed');
|
||||
},
|
||||
});
|
||||
|
||||
const { deleteApplication, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'managed'], ['application', serviceId]],
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
if (data?.resourceCredit) {
|
||||
toast.success('Service deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Service deleted');
|
||||
}
|
||||
router.push('/dashboard/services');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete'),
|
||||
});
|
||||
|
||||
const copyToClipboard = useCallback((text: string, field: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (app && (app.productType === 'application' || !app.productType)) {
|
||||
router.replace(`/dashboard/apps/${serviceId}`);
|
||||
}
|
||||
}, [app, router, serviceId]);
|
||||
|
||||
if (isLoading || !app) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="skeleton h-8 w-48" />
|
||||
<div className="card skeleton h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.productType === 'application' || !app.productType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestStatus = deployments[0]?.status || 'pending';
|
||||
const isDeployed = !!app.latestImageTag;
|
||||
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
|
||||
const isRunning = latestStatus === 'running';
|
||||
const isStopped = latestStatus === 'stopped';
|
||||
const renewalCost =
|
||||
selectedCycle === 'hourly'
|
||||
? renewalCostData?.costs.hourly
|
||||
: selectedCycle === 'yearly'
|
||||
? renewalCostData?.costs.yearly
|
||||
: renewalCostData?.costs.monthly;
|
||||
|
||||
const optionalRes =
|
||||
app.productType === 'managed_redis'
|
||||
? app.optionalServiceResources?.redis
|
||||
: app.productType === 'managed_rabbitmq'
|
||||
? app.optionalServiceResources?.rabbitmq
|
||||
: null;
|
||||
|
||||
const cpuDisplay =
|
||||
app.productType === 'managed_database'
|
||||
? `${app.cpuRequest || '100m'} / ${app.cpuLimit || '500m'}`
|
||||
: optionalRes
|
||||
? `${optionalRes.cpuRequest} / ${optionalRes.cpuLimit}`
|
||||
: '—';
|
||||
|
||||
const memDisplay =
|
||||
app.productType === 'managed_database'
|
||||
? `${app.memoryRequest || '128Mi'} / ${app.memoryLimit || '512Mi'}`
|
||||
: optionalRes
|
||||
? `${optionalRes.memoryRequest} / ${optionalRes.memoryLimit}`
|
||||
: '—';
|
||||
|
||||
const pageLocked = isAnyDeleting;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletingModal open={pageLocked} resourceName={app.name} resourceKind="service" />
|
||||
<div
|
||||
className={`space-y-6 ${pageLocked ? 'pointer-events-none select-none opacity-50' : ''}`}
|
||||
aria-hidden={pageLocked}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/services" className="btn-ghost">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="page-title">{app.name}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{managedServiceTypeLabel(app.productType)}
|
||||
{app.dbVersion ? ` · v${app.dbVersion}` : ''}
|
||||
{app.redisVersion && app.productType === 'managed_redis' ? ` · v${app.redisVersion}` : ''}
|
||||
{app.rabbitmqVersion && app.productType === 'managed_rabbitmq'
|
||||
? ` · v${app.rabbitmqVersion}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
{!isDeployed && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={deployMutation.isPending || needsRenewal}
|
||||
>
|
||||
<Rocket className="w-4 h-4 inline mr-1" /> Deploy
|
||||
</button>
|
||||
)}
|
||||
{isDeployed && !needsRenewal && !isInProgress && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
onClick={() => redeployMutation.mutate()}
|
||||
disabled={redeployMutation.isPending}
|
||||
title="Re-run Helm install for this service"
|
||||
>
|
||||
{redeployMutation.isPending ? (
|
||||
<Clock className="w-4 h-4 inline animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" />
|
||||
)}
|
||||
Redeploy
|
||||
</button>
|
||||
)}
|
||||
{isDeployed && isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary text-sm"
|
||||
onClick={() => restartMutation.mutate()}
|
||||
disabled={restartMutation.isPending || isInProgress}
|
||||
>
|
||||
{restartMutation.isPending ? (
|
||||
<Clock className="w-4 h-4 inline animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-4 h-4 inline mr-1" />
|
||||
)}
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger text-sm disabled:opacity-50"
|
||||
disabled={pageLocked}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Delete "${app.name}" permanently?`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteApplication(serviceId);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsRenewal && (
|
||||
<div className="rounded-xl p-4 border-2 bg-amber-50 border-amber-300 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-600 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-800">Payment required</h3>
|
||||
<p className="text-sm text-amber-600">Renew to restore this service.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRenewalModal(true)}
|
||||
className="btn-primary bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 inline mr-1" /> Renew
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.planExpiresAt && (
|
||||
<div className="card flex items-center gap-3 text-sm text-gray-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
Plan expires: {new Date(app.planExpiresAt).toLocaleString()}
|
||||
{app.billingCycle && ` (${app.billingCycle})`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration</h2>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Service type</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{managedServiceTypeLabel(app.productType)}</dd>
|
||||
</div>
|
||||
{app.productType === 'managed_database' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Database engine</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">
|
||||
{app.databaseType}
|
||||
{app.databaseType !== 'none' && app.dbVersion ? ` v${app.dbVersion}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{app.databaseType !== 'none' && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Storage</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{app.dbStorageSize || '1Gi'}</dd>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{app.productType === 'managed_redis' && app.redisVersion && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Redis version</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">v{app.redisVersion}</dd>
|
||||
</div>
|
||||
)}
|
||||
{app.productType === 'managed_rabbitmq' && app.rabbitmqVersion && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">RabbitMQ version</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">v{app.rabbitmqVersion}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">CPU</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{cpuDisplay}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Memory</dt>
|
||||
<dd className="text-sm font-medium text-gray-900">{memDisplay}</dd>
|
||||
</div>
|
||||
{app.billingCycle && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Billing</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 capitalize">{app.billingCycle}</dd>
|
||||
</div>
|
||||
)}
|
||||
{app.latestImageTag && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Deploy marker</dt>
|
||||
<dd className="text-xs font-mono text-gray-700 truncate max-w-[200px]" title={app.latestImageTag}>
|
||||
{app.latestImageTag}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Deployment History</h2>
|
||||
{deployments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-500 text-sm">No deployments yet</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Click Deploy to provision this service</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{deployments.slice(0, 10).map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between p-3 bg-gray-50/80 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{d.version || d.imageTag || 'Provision'}</p>
|
||||
<p className="text-xs text-gray-500">{new Date(d.createdAt).toLocaleString()}</p>
|
||||
{d.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1 truncate" title={d.errorMessage}>
|
||||
<XCircle className="w-3 h-3 inline" /> {d.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`badge ${statusColors[d.status] || 'badge-gray'} ml-2 shrink-0`}>
|
||||
{d.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{app.productType === 'managed_database' && app.databaseType !== 'none' && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" /> Connection
|
||||
<span className="badge badge-blue text-xs">{app.databaseType}</span>
|
||||
</h2>
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Internal cluster</h3>
|
||||
{[
|
||||
{ label: 'Host', value: `${app.name}-db`, field: 'host' },
|
||||
{ label: 'Port', value: dbPort(app.databaseType), field: 'port' },
|
||||
{ label: 'Database', value: app.name.replace(/-/g, '_'), field: 'database' },
|
||||
{ label: 'Username', value: app.dbUsername || 'appuser', field: 'username' },
|
||||
].map(({ label, value, field }) => (
|
||||
<div key={field} className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
{value}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value, field)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">Password</span>
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
{showDbPassword ? app.dbPassword || '—' : '••••••••'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showDbPassword ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(app.dbPassword || '', 'password')}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === 'password' ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 pt-2 border-t border-gray-200">
|
||||
Use external access below for internet-facing connections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(app.productType === 'managed_redis' || app.productType === 'managed_rabbitmq') && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" /> Connection
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowServiceSecrets(!showServiceSecrets)}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
{showServiceSecrets ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
{showServiceSecrets ? 'Hide secrets' : 'Show secrets'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{app.productType === 'managed_redis' && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Redis (internal)</h3>
|
||||
{[
|
||||
{ label: 'Host', value: serviceCredentials?.redis?.host || `${app.name}-redis`, field: 'redis-host' },
|
||||
{ label: 'Port', value: String(serviceCredentials?.redis?.port || 6379), field: 'redis-port' },
|
||||
{
|
||||
label: 'Password',
|
||||
value: serviceCredentials?.redis?.password || '',
|
||||
field: 'redis-password',
|
||||
secret: true,
|
||||
},
|
||||
{ label: 'URL', value: serviceCredentials?.redis?.url || '', field: 'redis-url', secret: true },
|
||||
].map(({ label, value, field, secret }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{app.productType === 'managed_rabbitmq' && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-700">RabbitMQ (internal)</h3>
|
||||
{[
|
||||
{
|
||||
label: 'Host',
|
||||
value: serviceCredentials?.rabbitmq?.host || `${app.name}-rabbitmq`,
|
||||
field: 'rabbit-host',
|
||||
},
|
||||
{
|
||||
label: 'AMQP Port',
|
||||
value: String(serviceCredentials?.rabbitmq?.amqpPort || 5672),
|
||||
field: 'rabbit-amqp-port',
|
||||
},
|
||||
{
|
||||
label: 'Management Port',
|
||||
value: String(serviceCredentials?.rabbitmq?.managementPort || 15672),
|
||||
field: 'rabbit-mgmt-port',
|
||||
},
|
||||
{
|
||||
label: 'Username',
|
||||
value: serviceCredentials?.rabbitmq?.username || 'appuser',
|
||||
field: 'rabbit-user',
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
value: serviceCredentials?.rabbitmq?.password || '',
|
||||
field: 'rabbit-password',
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
label: 'AMQP URL',
|
||||
value: serviceCredentials?.rabbitmq?.amqpUrl || '',
|
||||
field: 'rabbit-amqp-url',
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
label: 'Management URL',
|
||||
value: serviceCredentials?.rabbitmq?.managementUrl || '',
|
||||
field: 'rabbit-mgmt-url',
|
||||
},
|
||||
].map(({ label, value, field, secret }) => (
|
||||
<div key={field} className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono text-gray-800 truncate" title={value}>
|
||||
{secret && !showServiceSecrets ? '••••••••••••' : value || '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(value || '', field)}
|
||||
disabled={!value}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
{copiedField === field ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDeployed && (
|
||||
<p className="text-xs text-gray-400 mt-3">Deploy the service to load live credentials.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServiceExternalAccessPanel appId={serviceId} app={app} />
|
||||
|
||||
<ManagedServiceResourcesPanel
|
||||
serviceId={serviceId}
|
||||
app={app}
|
||||
isDeployed={isDeployed}
|
||||
isStopped={isStopped}
|
||||
needsRenewal={needsRenewal}
|
||||
/>
|
||||
|
||||
{app.productType === 'managed_database' && app.databaseType !== 'none' && (
|
||||
<DatabaseSnapshotsPanel serviceId={serviceId} isDeployed={isDeployed} />
|
||||
)}
|
||||
|
||||
<WorkloadLogsPanel
|
||||
appId={serviceId}
|
||||
showBuildLogs={false}
|
||||
isRunning={isRunning}
|
||||
isStopped={isStopped}
|
||||
emptyPodMessage={
|
||||
isRunning
|
||||
? 'Loading logs...'
|
||||
: isStopped
|
||||
? 'Service is stopped.'
|
||||
: 'Deploy or re-provision the service to see workload logs.'
|
||||
}
|
||||
/>
|
||||
|
||||
{showRenewalModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6">
|
||||
<h2 className="text-xl font-bold mb-2">Renew service</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">"{app.name}"</p>
|
||||
<div className="bg-gray-50 rounded-xl p-3 mb-4 flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Wallet className="w-4 h-4" /> Wallet
|
||||
</span>
|
||||
<strong>{walletData?.balance?.toLocaleString() ?? 0} T</strong>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||
<label
|
||||
key={cycle}
|
||||
className={`flex justify-between p-3 border-2 rounded-xl cursor-pointer ${
|
||||
selectedCycle === cycle ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
>
|
||||
<span className="capitalize font-medium">{cycle}</span>
|
||||
<span className="font-bold">
|
||||
{renewalCostData?.costs[cycle]?.toLocaleString() ?? '—'} T
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" className="btn-secondary flex-1" onClick={() => setShowRenewalModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex-1"
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => renewMutation.mutate(selectedCycle)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 inline mr-1" />
|
||||
Pay from wallet
|
||||
</button>
|
||||
</div>
|
||||
{renewalCost != null && walletData && walletData.balance < renewalCost && (
|
||||
<p className="text-xs text-amber-600 mt-3">
|
||||
Insufficient wallet balance. Top up your wallet or pay via invoice.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type {
|
||||
Application,
|
||||
CreateApplicationDto,
|
||||
DeployCostPreview,
|
||||
OptionalServiceResourcesMap,
|
||||
PricingCatalog,
|
||||
ProductType,
|
||||
} from '@/types';
|
||||
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||
import {
|
||||
ManagedDatabaseConfig,
|
||||
validateDbDumpStorage,
|
||||
RestoreStorageErrorModal,
|
||||
type ManagedDatabaseFormState,
|
||||
} from '@/components/managed-database-config';
|
||||
import { DatabaseWorkloadResources } from '@/components/database-workload-resources';
|
||||
import { OptionalServiceResourceFields } from '@/components/optional-service-resource-fields';
|
||||
import {
|
||||
Database,
|
||||
ArrowLeft,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
|
||||
|
||||
const steps = ['Service type', 'Configuration', 'Review & pay'];
|
||||
|
||||
export default function NewManagedServicePage() {
|
||||
const { notifyDeployStarted } = useDeployProgressActions();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [kind, setKind] = useState<ServiceKind | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
const [selectedCycle, setSelectedCycle] = useState<'hourly' | 'monthly' | 'yearly'>('monthly');
|
||||
const [deployStage, setDeployStage] = useState<
|
||||
'idle' | 'creating' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error'
|
||||
>('idle');
|
||||
const [dbUploadProgress, setDbUploadProgress] = useState(0);
|
||||
const [dbDumpFile, setDbDumpFile] = useState<File | null>(null);
|
||||
const [showRestoreStorageErrorModal, setShowRestoreStorageErrorModal] = useState(false);
|
||||
const [restoreStorageErrorMessage, setRestoreStorageErrorMessage] = useState('');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
cpuRequest: '100m',
|
||||
cpuLimit: '500m',
|
||||
memoryRequest: '256Mi',
|
||||
memoryLimit: '512Mi',
|
||||
databaseType: 'postgresql' as ManagedDatabaseFormState['databaseType'],
|
||||
dbVersion: '16',
|
||||
dbUsername: '',
|
||||
dbPassword: '',
|
||||
dbStorageSize: '1',
|
||||
redisVersion: '7.2',
|
||||
rabbitmqVersion: '3.13',
|
||||
optionalServiceResources: {} as OptionalServiceResourcesMap,
|
||||
});
|
||||
|
||||
const dbForm: ManagedDatabaseFormState = {
|
||||
databaseType: form.databaseType,
|
||||
dbVersion: form.dbVersion,
|
||||
dbUsername: form.dbUsername,
|
||||
dbPassword: form.dbPassword,
|
||||
dbStorageSize: form.dbStorageSize,
|
||||
};
|
||||
|
||||
const { data: pricingCatalog } = useQuery<PricingCatalog>({
|
||||
queryKey: ['pricing-catalog'],
|
||||
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
|
||||
enabled: step >= 1,
|
||||
});
|
||||
|
||||
const deployCostPayload = useMemo(() => {
|
||||
if (!kind) return null;
|
||||
const base = {
|
||||
productType: kind as ProductType,
|
||||
runtime: 'nodejs',
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryLimit: form.memoryLimit,
|
||||
replicas: 0,
|
||||
databaseType: 'none' as const,
|
||||
enableRedis: false,
|
||||
enableRabbitmq: false,
|
||||
enableElasticsearch: false,
|
||||
cycle: selectedCycle,
|
||||
};
|
||||
if (kind === 'managed_database') {
|
||||
return {
|
||||
...base,
|
||||
databaseType: form.databaseType,
|
||||
dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi`,
|
||||
};
|
||||
}
|
||||
if (kind === 'managed_redis') {
|
||||
return {
|
||||
...base,
|
||||
cpuLimit: '100m',
|
||||
memoryLimit: '128Mi',
|
||||
enableRedis: true,
|
||||
redisResources: form.optionalServiceResources?.redis,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
cpuLimit: '100m',
|
||||
memoryLimit: '128Mi',
|
||||
enableRabbitmq: true,
|
||||
rabbitmqResources: form.optionalServiceResources?.rabbitmq,
|
||||
};
|
||||
}, [kind, form, selectedCycle]);
|
||||
|
||||
const { data: costData, isLoading: costLoading } = useQuery<DeployCostPreview>({
|
||||
queryKey: ['deploy-cost', deployCostPayload],
|
||||
queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data),
|
||||
enabled: step === 2 && !!deployCostPayload,
|
||||
});
|
||||
|
||||
const { data: walletData } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
enabled: step === 2,
|
||||
});
|
||||
|
||||
const payAmount = costData?.amountDue ?? 0;
|
||||
const walletBalance = walletData?.balance ?? 0;
|
||||
const hasEnoughBalance = payAmount === 0 || walletBalance >= payAmount;
|
||||
const requiresPayment = (costData?.monthly ?? 0) > 0 && payAmount > 0;
|
||||
|
||||
const buildCreatePayload = (): CreateApplicationDto => {
|
||||
const productType = kind as ProductType;
|
||||
const payload: CreateApplicationDto = {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
productType,
|
||||
runtime: 'nodejs',
|
||||
databaseType: 'none',
|
||||
cpuRequest: form.cpuRequest,
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryRequest: form.memoryRequest,
|
||||
memoryLimit: form.memoryLimit,
|
||||
};
|
||||
if (kind === 'managed_database') {
|
||||
payload.databaseType = form.databaseType;
|
||||
payload.dbVersion = form.dbVersion;
|
||||
payload.dbUsername = form.dbUsername || undefined;
|
||||
payload.dbPassword = form.dbPassword || undefined;
|
||||
payload.dbStorageSize = `${parseInt(form.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
if (kind === 'managed_redis') {
|
||||
payload.enableRedis = true;
|
||||
payload.redisVersion = form.redisVersion;
|
||||
payload.optionalServiceResources = form.optionalServiceResources;
|
||||
}
|
||||
if (kind === 'managed_rabbitmq') {
|
||||
payload.enableRabbitmq = true;
|
||||
payload.rabbitmqVersion = form.rabbitmqVersion;
|
||||
payload.optionalServiceResources = form.optionalServiceResources;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const finishDeploy = async (appId: string, appName?: string) => {
|
||||
setDeployStage('deploying');
|
||||
notifyDeployStarted(appId, appName);
|
||||
try {
|
||||
await api.post(`/deployments/applications/${appId}/deploy`);
|
||||
} catch {
|
||||
useDeployProgressStore.getState().stopTracking(appId);
|
||||
throw new Error('Deploy failed');
|
||||
}
|
||||
setDeployStage('done');
|
||||
toast.success('Service provisioned successfully');
|
||||
router.push(`/dashboard/services/${appId}`);
|
||||
};
|
||||
|
||||
const uploadDbDump = async (appId: string) => {
|
||||
if (!dbDumpFile) return;
|
||||
setDeployStage('uploading-db');
|
||||
setDbUploadProgress(0);
|
||||
const fd = new FormData();
|
||||
fd.append('file', dbDumpFile);
|
||||
await api.post(`/applications/${appId}/db-upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (e.total) setDbUploadProgress(Math.round((e.loaded * 100) / e.total));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const walletPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setDeployStage('creating');
|
||||
const res = await api.post<Application>('/applications', buildCreatePayload());
|
||||
const appId = res.data.id;
|
||||
if (kind === 'managed_database' && dbDumpFile) {
|
||||
await uploadDbDump(appId);
|
||||
}
|
||||
setDeployStage('paying');
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
|
||||
return appId;
|
||||
},
|
||||
onSuccess: (appId) =>
|
||||
finishDeploy(appId, form.name).catch(() => {
|
||||
setDeployStage('error');
|
||||
toast.error('Payment succeeded but deployment failed');
|
||||
}),
|
||||
onError: (err: unknown) => {
|
||||
setDeployStage('error');
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Payment or provisioning failed');
|
||||
setTimeout(() => setDeployStage('idle'), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const gatewayPayMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setDeployStage('paying');
|
||||
if (payAmount > 0) {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Service: ${form.name} (${selectedCycle})`,
|
||||
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
||||
});
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: gw.trackingCode,
|
||||
amount: payAmount,
|
||||
});
|
||||
}
|
||||
setDeployStage('creating');
|
||||
const res = await api.post<Application>('/applications', buildCreatePayload());
|
||||
const appId = res.data.id;
|
||||
if (kind === 'managed_database' && dbDumpFile) {
|
||||
await uploadDbDump(appId);
|
||||
}
|
||||
await api.post(`/billing/wallet/pay/${appId}`, { cycle: selectedCycle });
|
||||
return appId;
|
||||
},
|
||||
onSuccess: (appId) =>
|
||||
finishDeploy(appId, form.name).catch(() => {
|
||||
setDeployStage('error');
|
||||
toast.error('Payment succeeded but deployment failed');
|
||||
}),
|
||||
onError: (err: unknown) => {
|
||||
setDeployStage('error');
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Payment failed');
|
||||
setTimeout(() => setDeployStage('idle'), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
const validateBeforePay = () => {
|
||||
if (kind === 'managed_database' && dbDumpFile) {
|
||||
const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1);
|
||||
if (err) {
|
||||
setRestoreStorageErrorMessage(err);
|
||||
setShowRestoreStorageErrorModal(true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePay = () => {
|
||||
if (!validateBeforePay()) return;
|
||||
if (payAmount === 0) walletPayMutation.mutate();
|
||||
else if (paymentMethod === 'wallet') {
|
||||
if (!hasEnoughBalance) {
|
||||
toast.error('Insufficient wallet balance');
|
||||
return;
|
||||
}
|
||||
walletPayMutation.mutate();
|
||||
} else {
|
||||
gatewayPayMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 0) return !!kind;
|
||||
if (step === 1) return /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(form.name);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/services" className="btn-ghost">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="page-title">New managed service</h1>
|
||||
<p className="page-subtitle">Database, Redis, or RabbitMQ — billed like applications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((label, i) => (
|
||||
<div key={label} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-full text-sm font-bold ${
|
||||
i < step
|
||||
? 'bg-emerald-500 text-white'
|
||||
: i === step
|
||||
? 'bg-primary-600 text-white ring-4 ring-primary-100'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{i < step ? '✓' : i + 1}
|
||||
</div>
|
||||
<span className={`mt-1.5 text-xs font-medium hidden sm:block ${i <= step ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 rounded-full ${i < step ? 'bg-emerald-400' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{step === 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
{ id: 'managed_database' as const, title: 'Database', desc: 'PostgreSQL, MySQL, MariaDB, MongoDB' },
|
||||
{ id: 'managed_redis' as const, title: 'Redis', desc: 'In-memory cache & store' },
|
||||
{ id: 'managed_rabbitmq' as const, title: 'RabbitMQ', desc: 'Message broker' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKind(opt.id);
|
||||
if (opt.id === 'managed_redis' && !form.optionalServiceResources?.redis) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
optionalServiceResources: {
|
||||
redis: optionalDefaultsFromCatalog(pricingCatalog, 'redis'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (opt.id === 'managed_rabbitmq' && !form.optionalServiceResources?.rabbitmq) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
optionalServiceResources: {
|
||||
rabbitmq: optionalDefaultsFromCatalog(pricingCatalog, 'rabbitmq'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className={`p-5 rounded-xl border-2 text-left transition-all ${
|
||||
kind === opt.id ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Database className={`w-8 h-8 mb-2 ${kind === opt.id ? 'text-primary-600' : 'text-gray-400'}`} />
|
||||
<p className="font-semibold text-gray-900">{opt.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && kind && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Service name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||
placeholder="my-database"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
{kind === 'managed_database' && (
|
||||
<>
|
||||
<ManagedDatabaseConfig
|
||||
form={dbForm}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
dbDumpFile={dbDumpFile}
|
||||
onDbDumpFileChange={setDbDumpFile}
|
||||
/>
|
||||
<DatabaseWorkloadResources
|
||||
values={{
|
||||
cpuRequest: form.cpuRequest,
|
||||
cpuLimit: form.cpuLimit,
|
||||
memoryRequest: form.memoryRequest,
|
||||
memoryLimit: form.memoryLimit,
|
||||
}}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'managed_redis' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Redis version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.redisVersion}
|
||||
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||
>
|
||||
{['7.2', '7.0', '6.2', '6.0'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.optionalServiceResources?.redis && (
|
||||
<OptionalServiceResourceFields
|
||||
title="Redis resources"
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-400"
|
||||
bgClass="bg-red-50"
|
||||
config={form.optionalServiceResources.redis}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
redis: { ...form.optionalServiceResources!.redis!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'managed_rabbitmq' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">RabbitMQ version</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.rabbitmqVersion}
|
||||
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||
>
|
||||
{['3.13', '3.12', '3.11', '3.10'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.optionalServiceResources?.rabbitmq && (
|
||||
<OptionalServiceResourceFields
|
||||
title="RabbitMQ resources"
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-400"
|
||||
bgClass="bg-orange-50"
|
||||
config={form.optionalServiceResources.rabbitmq}
|
||||
onChange={(patch) =>
|
||||
setForm({
|
||||
...form,
|
||||
optionalServiceResources: {
|
||||
...form.optionalServiceResources,
|
||||
rabbitmq: { ...form.optionalServiceResources!.rabbitmq!, ...patch },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
{costLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
) : costData ? (
|
||||
<>
|
||||
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-200">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Billing cycle</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
type="button"
|
||||
onClick={() => setSelectedCycle(cycle)}
|
||||
className={`py-3 rounded-lg border-2 text-sm font-medium capitalize ${
|
||||
selectedCycle === cycle ? 'border-emerald-500 bg-white' : 'border-transparent bg-white/50'
|
||||
}`}
|
||||
>
|
||||
{cycle}
|
||||
<span className="block text-lg font-bold text-emerald-700 mt-1">
|
||||
{Number(costData[cycle]).toLocaleString('en-US')} T
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{requiresPayment && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Payment method</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('wallet')}
|
||||
className={`p-4 rounded-xl border-2 text-left ${
|
||||
paymentMethod === 'wallet' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-5 h-5 text-primary-600" />
|
||||
<p className="font-semibold text-sm mt-2">Wallet</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block">Insufficient</span>}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('gateway')}
|
||||
className={`p-4 rounded-xl border-2 text-left ${
|
||||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||||
<p className="font-semibold text-sm mt-2">Pay now</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center">Pricing unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||
<button type="button" onClick={() => setStep(step - 1)} disabled={step === 0} className="btn-ghost disabled:opacity-0">
|
||||
← Back
|
||||
</button>
|
||||
{step < 2 ? (
|
||||
<button type="button" onClick={() => setStep(step + 1)} disabled={!canNext()} className="btn-primary disabled:opacity-50">
|
||||
Next →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={walletPayMutation.isPending || gatewayPayMutation.isPending || deployStage !== 'idle'}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
onClick={handlePay}
|
||||
>
|
||||
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||||
) : (
|
||||
'Pay & provision'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deployStage !== 'idle' && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="font-bold text-gray-900">Provisioning service</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'creating' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary-600" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
Creating service
|
||||
</div>
|
||||
{dbDumpFile && kind === 'managed_database' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'uploading-db' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary-600" />
|
||||
) : ['paying', 'deploying', 'done'].includes(deployStage) ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Uploading database dump
|
||||
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
||||
<span className="text-primary-600 font-semibold">{dbUploadProgress}%</span>
|
||||
)}
|
||||
</div>
|
||||
{deployStage === 'uploading-db' && (
|
||||
<div className="ml-6 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary-500 transition-all" style={{ width: `${dbUploadProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'paying' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : ['deploying', 'done'].includes(deployStage) ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Payment
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'deploying' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : deployStage === 'done' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : deployStage === 'error' ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Deploying
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RestoreStorageErrorModal
|
||||
open={showRestoreStorageErrorModal}
|
||||
message={restoreStorageErrorMessage}
|
||||
onClose={() => setShowRestoreStorageErrorModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import type { Application } from '@/types';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
||||
import {
|
||||
DeletingCardOverlay,
|
||||
deletingResourceMessage,
|
||||
deletingRowContentClass,
|
||||
} from '@/components/deleting-overlay';
|
||||
import { filterManagedServices } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
|
||||
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 serviceSubtitle(app: Application): string {
|
||||
if (app.productType === 'managed_database') {
|
||||
return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_redis') {
|
||||
return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_rabbitmq') {
|
||||
return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`;
|
||||
}
|
||||
return app.databaseType !== 'none' ? app.databaseType : '—';
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
if (!expiresAt) return { text: '—', urgent: false };
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
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 ServicesPage() {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: servicesRaw = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'managed'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
||||
});
|
||||
const services = filterManagedServices(servicesRaw);
|
||||
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'managed']],
|
||||
successMessage: 'Service deleted',
|
||||
successWithCreditMessage: 'Service deleted. Prepaid resources are on your dashboard.',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-56" />
|
||||
<div className="skeleton h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Databases & Services</h1>
|
||||
<p className="page-subtitle">
|
||||
Standalone databases, Redis, and RabbitMQ — {services.length} service{services.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/services/new" className="btn-primary">
|
||||
<Plus className="w-4 h-4 mr-1 inline" /> New Service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Database className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No managed services yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Provision a database, Redis, or RabbitMQ without deploying a full application.
|
||||
</p>
|
||||
<Link href="/dashboard/services/new" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> Create your first service
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{services.map((svc) => {
|
||||
const lifecycle = svc.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(svc.planExpiresAt);
|
||||
const latestStatus = svc.deployments?.[0]?.status || 'pending';
|
||||
const cardDeleting = isDeleting(svc.id);
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
className={`relative card-hover flex flex-col sm:flex-row sm:items-center gap-4 ${
|
||||
lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : ''
|
||||
} ${lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''} ${cardDeleting ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
{cardDeleting && (
|
||||
<DeletingCardOverlay message={deletingResourceMessage('service', svc.name)} />
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row sm:items-center gap-4 flex-1 w-full min-w-0 ${cardDeleting ? deletingRowContentClass : ''}`}
|
||||
>
|
||||
<Link href={`/dashboard/services/${svc.id}`} className="flex items-center gap-3 flex-1 min-w-0 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Database className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 group-hover:text-primary-600 truncate">{svc.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<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>
|
||||
<span className={`text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
{expiry.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm text-red-600 disabled:opacity-50 disabled:pointer-events-none"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Permanently delete "${svc.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteApplication(svc.id);
|
||||
}}
|
||||
>
|
||||
<DeleteButtonLabel loading={isDeleting(svc.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Ticket, TicketStatus } from '@/types';
|
||||
import { Wrench, Briefcase, User, CheckCircle } from 'lucide-react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-blue-100 text-blue-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export default function StaffTicketsPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
|
||||
|
||||
// Determine which department this staff member handles
|
||||
const department = user?.role === 'sales' ? 'sales' : 'technical';
|
||||
const departmentLabel = department === 'technical' ? 'Technical' : 'Sales';
|
||||
const DeptIcon = department === 'technical' ? Wrench : Briefcase;
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
||||
queryKey: ['staff-tickets', department, statusFilter],
|
||||
queryFn: () => {
|
||||
const params = statusFilter ? `?status=${statusFilter}` : '';
|
||||
return api.get(`/tickets/staff/${department}${params}`).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const unansweredCount = tickets.filter((t) => t.status === 'open' || t.status === 'waiting').length;
|
||||
const answeredCount = tickets.filter((t) => t.status === 'answered').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"><DeptIcon className="w-5 h-5" /> {departmentLabel} Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{tickets.length} total tickets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Unanswered</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${unansweredCount > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{unansweredCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Need response</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Answered</p>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">{answeredCount}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Waiting for user</p>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold">Total Open</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{tickets.filter((t) => t.status !== 'closed').length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Excluding closed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === status
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status === '' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tickets List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<CheckCircle className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/tickets/${ticket.id}`}
|
||||
className="card p-4 block hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
{ticket.user && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email})
|
||||
</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Ticket } from '@/types';
|
||||
import { Wrench, Briefcase } from 'lucide-react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
export default function TicketDetailPage() {
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [reply, setReply] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: ticket, isLoading } = useQuery<Ticket>({
|
||||
queryKey: ['ticket', id],
|
||||
queryFn: () => api.get(`/tickets/${id}`).then((r) => r.data),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const replyMutation = useMutation({
|
||||
mutationFn: (message: string) => api.post(`/tickets/${id}/reply`, { message }).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
setReply('');
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Reply sent');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to send reply'),
|
||||
});
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', id] });
|
||||
toast.success('Ticket closed');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [ticket?.messages]);
|
||||
|
||||
const handleSubmitReply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (reply.trim()) {
|
||||
replyMutation.mutate(reply.trim());
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return <div className="card p-12 text-center text-gray-500">Ticket not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<button onClick={() => router.back()} className="text-sm text-gray-500 hover:text-gray-700 mb-2">
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ticket.subject}</h1>
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(ticket.createdAt).toLocaleString()}
|
||||
</span>
|
||||
{ticket.user && (
|
||||
<span className="text-xs text-gray-400">
|
||||
by {ticket.user.firstName} {ticket.user.lastName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ticket.status !== 'closed' && (
|
||||
<button
|
||||
onClick={() => closeMutation.mutate()}
|
||||
className="btn-ghost text-sm text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={closeMutation.isPending}
|
||||
>
|
||||
Close Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="card p-4 space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{ticket.messages?.map((msg) => {
|
||||
const isMe = msg.senderId === user?.id;
|
||||
const isStaff = msg.senderRole !== 'user';
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
isMe
|
||||
? 'bg-primary-500 text-white rounded-br-md'
|
||||
: isStaff
|
||||
? 'bg-blue-50 text-gray-900 border border-blue-200 rounded-bl-md'
|
||||
: 'bg-gray-100 text-gray-900 rounded-bl-md'
|
||||
}`}>
|
||||
{!isMe && (
|
||||
<p className={`text-xs font-semibold mb-1 ${isStaff ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||
{msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : 'Unknown'}
|
||||
{isStaff && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-[10px]">
|
||||
{msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.message}</p>
|
||||
<p className={`text-[10px] mt-1 ${isMe ? 'text-white/70' : 'text-gray-400'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Reply Box */}
|
||||
{ticket.status !== 'closed' ? (
|
||||
<form onSubmit={handleSubmitReply} className="card p-4">
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
className="input-field flex-1 min-h-[60px] resize-none"
|
||||
placeholder="Type your reply..."
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmitReply(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary self-end"
|
||||
disabled={!reply.trim() || replyMutation.isPending}
|
||||
>
|
||||
{replyMutation.isPending ? '...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="card p-4 text-center text-sm text-gray-500">
|
||||
This ticket is closed. Create a new ticket if you need further assistance.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
||||
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
answered: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-blue-100 text-blue-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
// Staff can only create tickets to other departments
|
||||
const availableDepartments: { value: TicketDepartment; label: string }[] = [];
|
||||
if (user?.role !== 'technical') {
|
||||
availableDepartments.push({ value: 'technical', label: 'Technical Support' });
|
||||
}
|
||||
if (user?.role !== 'sales') {
|
||||
availableDepartments.push({ value: 'sales', label: 'Sales' });
|
||||
}
|
||||
const canCreateTicket = availableDepartments.length > 0;
|
||||
const [form, setForm] = useState<CreateTicketDto>({
|
||||
subject: '',
|
||||
department: availableDepartments[0]?.value || 'technical',
|
||||
priority: 'medium',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
||||
queryKey: ['my-tickets'],
|
||||
queryFn: () => api.get('/tickets/my').then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
toast.success('Ticket created successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
||||
setShowCreate(false);
|
||||
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to create ticket'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Tickets</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Support tickets and their status</p>
|
||||
</div>
|
||||
{canCreateTicket && (
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
||||
{showCreate ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ New Ticket'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Ticket Form */}
|
||||
{showCreate && canCreateTicket && (
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New Ticket</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="Brief description of your issue"
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Department</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.department}
|
||||
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
|
||||
>
|
||||
{availableDepartments.map((dept) => (
|
||||
<option key={dept.value} value={dept.value}>{dept.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Message</label>
|
||||
<textarea
|
||||
className="input-field min-h-[120px]"
|
||||
placeholder="Describe your issue in detail..."
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
required
|
||||
minLength={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Submit Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Tickets List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<TicketIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-700">No tickets yet</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Create a ticket if you need help</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/dashboard/tickets/${ticket.id}`}
|
||||
className="card p-4 block hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
||||
{ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> Technical</> : <><Briefcase className="w-3 h-3" /> Sales</>}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
<span>{ticket.messages?.length || 0} message{(ticket.messages?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { WalletTransaction, TransactionType } from '@/types';
|
||||
import Link from 'next/link';
|
||||
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock, CreditCard, FileText } from 'lucide-react';
|
||||
|
||||
const txTypeLabels: Record<TransactionType, string> = {
|
||||
charge: 'Deposit',
|
||||
deduction: 'Payment',
|
||||
refund: 'Refund',
|
||||
gateway_payment: 'Gateway payment',
|
||||
};
|
||||
|
||||
const txTypeColors: Record<TransactionType, string> = {
|
||||
charge: 'text-green-600',
|
||||
deduction: 'text-red-600',
|
||||
refund: 'text-blue-600',
|
||||
gateway_payment: 'text-purple-600',
|
||||
};
|
||||
|
||||
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||||
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
||||
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
||||
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
|
||||
gateway_payment: <CreditCard className="w-4 h-4 text-purple-500" />,
|
||||
};
|
||||
|
||||
export default function WalletPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [chargeAmount, setChargeAmount] = useState('');
|
||||
const [showCharge, setShowCharge] = useState(false);
|
||||
|
||||
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||||
queryKey: ['wallet-transactions'],
|
||||
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Direct wallet charge (simulated — in production this would go through payment gateway)
|
||||
const chargeMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
api.post('/billing/wallet/charge', { amount, description: 'Wallet top-up' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Wallet charged successfully');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to charge wallet'),
|
||||
});
|
||||
|
||||
// Payment gateway charge
|
||||
const gatewayMutation = useMutation({
|
||||
mutationFn: async (amount: number) => {
|
||||
const { data } = await api.post('/billing/gateway/initiate', {
|
||||
amount,
|
||||
description: 'Wallet top-up via gateway',
|
||||
callbackUrl: `${window.location.origin}/dashboard/wallet`,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
// In production, redirect to data.gatewayUrl
|
||||
// For now, auto-verify (simulated)
|
||||
await api.post('/billing/gateway/verify', {
|
||||
trackingCode: data.trackingCode,
|
||||
amount: Number(chargeAmount),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||
toast.success('Payment successful — wallet charged');
|
||||
setChargeAmount('');
|
||||
setShowCharge(false);
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Payment failed'),
|
||||
});
|
||||
|
||||
const handleCharge = (method: 'wallet' | 'gateway') => {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount < 1000) {
|
||||
toast.error('Minimum charge amount is 1,000 Toman');
|
||||
return;
|
||||
}
|
||||
if (method === 'wallet') {
|
||||
chargeMutation.mutate(amount);
|
||||
} else {
|
||||
gatewayMutation.mutate(amount);
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const isPending = chargeMutation.isPending || gatewayMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> Wallet</h1>
|
||||
<p className="page-subtitle">Manage your balance and transactions</p>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm opacity-80">Current Balance</p>
|
||||
<p className="text-3xl font-bold mt-1">
|
||||
{walletLoading ? '...' : formatPrice(walletData?.balance ?? 0)}
|
||||
<span className="text-lg font-normal ml-2">Toman</span>
|
||||
</p>
|
||||
</div>
|
||||
{!showCharge && (
|
||||
<button
|
||||
onClick={() => setShowCharge(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Top Up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCharge && (
|
||||
<div className="mt-4 pt-4 border-t border-white/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||||
placeholder="Amount (Toman) — min 1,000"
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
min={1000}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCharge('gateway')}
|
||||
disabled={isPending}
|
||||
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{gatewayMutation.isPending ? '...' : 'Pay Now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCharge(false)}
|
||||
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick charge amounts */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{[10000, 50000, 100000, 500000].map((amt) => (
|
||||
<button
|
||||
key={amt}
|
||||
onClick={() => setChargeAmount(String(amt))}
|
||||
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
{amt.toLocaleString('en-US')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transactions */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-400" /> Transaction History
|
||||
</h2>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">No transactions yet</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
{txTypeIcons[tx.type]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{txTypeLabels[tx.type]}
|
||||
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||
{tx.invoiceId && (
|
||||
<Link
|
||||
href={`/dashboard/invoices?invoice=${tx.invoiceId}`}
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Invoice {tx.invoice?.invoiceNumber || ''}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||
{tx.type === 'deduction' ? '−' : tx.type === 'gateway_payment' ? '' : '+'}{formatPrice(tx.amount)} T
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">Balance: {formatPrice(tx.balanceAfter)} T</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user