Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,10 +4,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget, OptionalServiceCredentials, Invoice } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, OptionalServiceCredentials, Invoice } from '@/types';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import NextLink from 'next/link';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink, ScrollText } from 'lucide-react';
|
||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ScrollText } from 'lucide-react';
|
||||
import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
|
||||
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
||||
import { isApplicationProduct, isManagedProduct } from '@/lib/product-type';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
@@ -57,10 +60,7 @@ export default function AppDetailPage() {
|
||||
const appId = params.id as string;
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const logsEndRef = useRef<HTMLPreElement>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
@@ -98,17 +98,19 @@ export default function AppDetailPage() {
|
||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
|
||||
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
|
||||
const [accessDuration, setAccessDuration] = useState(60);
|
||||
const [showAccessSecret, setShowAccessSecret] = useState(false);
|
||||
const [showServiceSecrets, setShowServiceSecrets] = useState(false);
|
||||
const [accessNow, setAccessNow] = useState(() => Date.now());
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (app && isManagedProduct(app)) {
|
||||
router.replace(`/dashboard/services/${appId}`);
|
||||
}
|
||||
}, [app, appId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!app) return;
|
||||
if (scaleWorkload === 'database' && app.databaseType === 'none') setScaleWorkload('app');
|
||||
@@ -128,20 +130,6 @@ export default function AppDetailPage() {
|
||||
enabled: !!app?.latestImageTag && (!!app?.enableRedis || !!app?.enableRabbitmq),
|
||||
});
|
||||
|
||||
const { data: logsData } = useQuery<{ logs: string }>({
|
||||
queryKey: ['logs', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}/logs`).then((r) => r.data),
|
||||
enabled: showLogs && logTab === 'pod',
|
||||
refetchInterval: showLogs && logTab === 'pod' ? 3000 : false,
|
||||
});
|
||||
|
||||
const { data: buildLogsData } = useQuery<{ buildLog: string | null; status: string; version: string | null }>({
|
||||
queryKey: ['build-logs', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}/build-logs`).then((r) => r.data),
|
||||
enabled: showLogs && logTab === 'build',
|
||||
refetchInterval: showLogs && logTab === 'build' ? 5000 : false,
|
||||
});
|
||||
|
||||
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
|
||||
queryKey: ['resources', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/resources`).then((r) => r.data),
|
||||
@@ -537,12 +525,6 @@ export default function AppDetailPage() {
|
||||
}, [resourceUsage, scaleWorkload, resourceFormDirty]);
|
||||
|
||||
// Auto-scroll logs to bottom
|
||||
useEffect(() => {
|
||||
if (logsEndRef.current) {
|
||||
logsEndRef.current.scrollTop = logsEndRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsData]);
|
||||
|
||||
const invalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
@@ -589,7 +571,7 @@ export default function AppDetailPage() {
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
|
||||
onSuccess: () => {
|
||||
invalidateAll();
|
||||
toast.success('Redeploy triggered — building new version from latest source');
|
||||
toast.success('Redeploy started');
|
||||
},
|
||||
onError: () => toast.error('Failed to trigger redeploy'),
|
||||
});
|
||||
@@ -700,77 +682,6 @@ export default function AppDetailPage() {
|
||||
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||||
});
|
||||
|
||||
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
|
||||
if (app?.databaseType && app.databaseType !== 'none') {
|
||||
accessTargetOptions.push({ value: 'database', label: 'Database' });
|
||||
}
|
||||
if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' });
|
||||
if (app?.enableRabbitmq) {
|
||||
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
|
||||
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
|
||||
}
|
||||
const hasAccessTargets = accessTargetOptions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccessTargets) return;
|
||||
if (!accessTargetOptions.some((o) => o.value === accessTarget)) {
|
||||
setAccessTarget(accessTargetOptions[0].value);
|
||||
}
|
||||
}, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]);
|
||||
|
||||
const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery<ServiceAccessGrant[]>({
|
||||
queryKey: ['access-grants', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data),
|
||||
enabled: hasAccessTargets && !!app?.latestImageTag,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessGrants.some((g) => g.status === 'active')) return;
|
||||
const t = setInterval(() => setAccessNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [accessGrants]);
|
||||
|
||||
const createAccessMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/applications/${appId}/access`, {
|
||||
target: accessTarget,
|
||||
durationMinutes: accessDuration,
|
||||
}).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
refetchAccessGrants();
|
||||
toast.success('Temporary external access enabled');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to enable access');
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAccessMutation = useMutation({
|
||||
mutationFn: (grantId: string) =>
|
||||
api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
refetchAccessGrants();
|
||||
toast.success('Access revoked');
|
||||
},
|
||||
onError: () => toast.error('Failed to revoke access'),
|
||||
});
|
||||
|
||||
const accessTargetLabel = (target: ServiceAccessTarget) =>
|
||||
accessTargetOptions.find((o) => o.value === target)?.label || target;
|
||||
|
||||
const formatAccessCountdown = (expiresAt: string) => {
|
||||
const ms = new Date(expiresAt).getTime() - accessNow;
|
||||
if (ms <= 0) return 'Expired';
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const formData = new FormData();
|
||||
@@ -901,6 +812,10 @@ export default function AppDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isManagedProduct(app)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestStatus = deployments[0]?.status || 'pending';
|
||||
const hasDeployments = deployments.length > 0;
|
||||
const isStopped = latestStatus === 'stopped';
|
||||
@@ -970,7 +885,7 @@ export default function AppDetailPage() {
|
||||
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
|
||||
</button>
|
||||
)}
|
||||
{!isInProgress && hasPaidAccess && (
|
||||
{!isInProgress && hasPaidAccess && isApplicationProduct(app) && (
|
||||
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
||||
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
||||
</button>
|
||||
@@ -1926,129 +1841,7 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Temporary External Access */}
|
||||
{hasAccessTargets && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5" /> Temporary External Access
|
||||
</h2>
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.
|
||||
</p>
|
||||
|
||||
{!app?.latestImageTag ? (
|
||||
<p className="text-sm text-gray-500">Deploy the application first to enable external access.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
|
||||
<select
|
||||
value={accessTarget}
|
||||
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{accessTargetOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
|
||||
<div className="flex gap-2">
|
||||
{[30, 60, 240].map((mins) => (
|
||||
<button
|
||||
key={mins}
|
||||
type="button"
|
||||
onClick={() => setAccessDuration(mins)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
accessDuration === mins
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createAccessMutation.mutate()}
|
||||
disabled={createAccessMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createAccessMutation.isPending ? 'Opening…' : 'Enable access'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No active external access sessions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accessGrants
|
||||
.filter((g) => g.status === 'active')
|
||||
.map((grant) => (
|
||||
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">ends in {formatAccessCountdown(grant.expiresAt)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revokeAccessMutation.mutate(grant.id)}
|
||||
disabled={revokeAccessMutation.isPending}
|
||||
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-sm font-mono">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-800">{grant.host}:{grant.port}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
title="Copy"
|
||||
>
|
||||
{copiedField === `access-endpoint-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{grant.connection.url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-gray-500 text-xs font-sans">URL</span>
|
||||
<div className="flex items-center gap-2 max-w-[70%]">
|
||||
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
|
||||
{showAccessSecret ? grant.connection.url : '••••••••••••'}
|
||||
</span>
|
||||
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{copiedField === `access-url-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{app && <ServiceExternalAccessPanel appId={appId} app={app} />}
|
||||
|
||||
{/* Resource Monitoring & Scaling */}
|
||||
<div className="card">
|
||||
@@ -2863,94 +2656,12 @@ export default function AppDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs — Pod & Build */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><FileText className="w-5 h-5" /> Logs</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{showLogs && logTab === 'pod' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>Live (every 3s)</span>
|
||||
</span>
|
||||
)}
|
||||
{showLogs && logTab === 'build' && (
|
||||
<span className="text-xs text-gray-400 flex items-center space-x-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span>Auto-refresh (every 5s)</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
{showLogs ? <><ChevronDown className="w-4 h-4 inline" /> Hide Logs</> : <><FileText className="w-4 h-4 inline" /> Show Logs</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showLogs && (
|
||||
<div className="space-y-3">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
|
||||
<button
|
||||
onClick={() => setLogTab('pod')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'pod'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4 inline" /> Pod Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLogTab('build')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
logTab === 'build'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Hammer className="w-4 h-4 inline" /> Build Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pod logs */}
|
||||
{logTab === 'pod' && (
|
||||
<pre
|
||||
ref={logsEndRef}
|
||||
className="bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words"
|
||||
>
|
||||
{logsData?.logs || (isRunning ? 'Loading logs...' : isStopped ? 'Application is stopped. Start it to see logs.' : 'Waiting for pod to be ready...')}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Build logs */}
|
||||
{logTab === 'build' && (
|
||||
<div>
|
||||
{buildLogsData?.version && (
|
||||
<div className="flex items-center gap-3 mb-2 text-xs text-gray-500">
|
||||
<span><Pin className="w-3 h-3 inline" /> {buildLogsData.version}</span>
|
||||
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
|
||||
{buildLogsData.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{buildLogsData?.buildLog || (
|
||||
buildLogsData?.status === 'building'
|
||||
? 'Build in progress... Logs will appear when complete.'
|
||||
: buildLogsData?.status === 'pending'
|
||||
? 'Build is pending...'
|
||||
: buildLogsData?.status === 'no_deployment'
|
||||
? 'No deployments yet. Deploy your app to see build logs.'
|
||||
: 'No build logs available for this deployment.'
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<WorkloadLogsPanel
|
||||
appId={appId}
|
||||
showBuildLogs={isApplicationProduct(app)}
|
||||
isRunning={isRunning}
|
||||
isStopped={isStopped}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { filterApplications } from '@/lib/product-type';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
@@ -51,10 +52,11 @@ export default function AppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
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 deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CreditCard,
|
||||
ScrollText,
|
||||
FileText,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
|
||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
||||
@@ -35,6 +36,7 @@ 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" /> },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
@@ -73,10 +73,42 @@ function LogsPageContent() {
|
||||
});
|
||||
|
||||
const { data: applications = [] } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
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();
|
||||
@@ -163,7 +195,7 @@ function LogsPageContent() {
|
||||
<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">Application</label>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">Resource</label>
|
||||
<select
|
||||
value={appId}
|
||||
onChange={(e) => {
|
||||
@@ -172,12 +204,25 @@ function LogsPageContent() {
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">All applications</option>
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
<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>
|
||||
@@ -190,7 +235,7 @@ function LogsPageContent() {
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{WORKLOADS.map((w) => (
|
||||
{workloadOptions.map((w) => (
|
||||
<option key={w.value || 'all'} value={w.value}>
|
||||
{w.label}
|
||||
</option>
|
||||
|
||||
@@ -6,8 +6,10 @@ 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 } from 'lucide-react';
|
||||
import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react';
|
||||
import type { ResourceCredit } from '@/types';
|
||||
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',
|
||||
@@ -31,36 +33,61 @@ const statusIcons: Record<string, ReactNode> = {
|
||||
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: apps = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications'],
|
||||
queryFn: () => api.get('/applications').then((r) => r.data),
|
||||
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 = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'running'),
|
||||
);
|
||||
const failedApps = apps.filter(
|
||||
(a) => a.deployments?.some((d) => d.status === 'failed' || d.status === 'build_failed'),
|
||||
);
|
||||
const runningApps = countRunning(apps);
|
||||
const failedApps = countFailed(apps);
|
||||
const runningServices = countRunning(services);
|
||||
const failedServices = countFailed(services);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="page-title">
|
||||
Welcome back, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
Here's an overview of your applications.
|
||||
</p>
|
||||
<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 && (
|
||||
@@ -70,7 +97,8 @@ export default function DashboardPage() {
|
||||
<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.
|
||||
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>
|
||||
@@ -104,58 +132,59 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Apps</div>
|
||||
<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">Running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningApps.length}</div>
|
||||
<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">Failed</div>
|
||||
<div className="stat-value text-red-600">{isLoading ? '—' : failedApps.length}</div>
|
||||
<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">Total Deploys</div>
|
||||
<div className="stat-value text-primary-600">
|
||||
{isLoading ? '—' : apps.reduce((sum, a) => sum + (a.deployments?.length || 0), 0)}
|
||||
</div>
|
||||
<div className="stat-label">Services running</div>
|
||||
<div className="stat-value text-emerald-600">{isLoading ? '—' : runningServices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Applications */}
|
||||
{(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>
|
||||
<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
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New application
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{appsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
{[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-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<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>
|
||||
@@ -170,14 +199,14 @@ export default function DashboardPage() {
|
||||
href={`/dashboard/apps/${app.id}`}
|
||||
className="card-hover flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<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'}`} />
|
||||
<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 transition-colors truncate">
|
||||
{app.name}
|
||||
</h3>
|
||||
<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}`}
|
||||
@@ -186,24 +215,89 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusIcons[status]}
|
||||
<span className={`badge ${statusColors[status] || 'badge-gray'}`}>
|
||||
{status}
|
||||
</span>
|
||||
<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-3"
|
||||
>
|
||||
<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,701 @@
|
||||
'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 { 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 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`),
|
||||
onSuccess: () => {
|
||||
toast.success('Provisioning started');
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
},
|
||||
onError: () => toast.error('Failed to start provisioning'),
|
||||
});
|
||||
|
||||
const redeployMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`),
|
||||
onSuccess: () => {
|
||||
toast.success('Re-provisioning started');
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
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 deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${serviceId}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.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}`
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Delete "${app.name}" permanently?`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
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,640 @@
|
||||
'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 {
|
||||
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 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) => {
|
||||
setDeployStage('deploying');
|
||||
await api.post(`/deployments/applications/${appId}/deploy`);
|
||||
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).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).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,185 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application } from '@/types';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { filterManagedServices } from '@/lib/product-type';
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
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 deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
toast.success('Service deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Service deleted');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to delete service'),
|
||||
});
|
||||
|
||||
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';
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
className={`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' : ''}`}
|
||||
>
|
||||
<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"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Permanently delete "${svc.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(svc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user