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:
keyhan
2026-05-23 19:00:09 +03:30
parent 736509708b
commit 695e05f948
55 changed files with 5575 additions and 600 deletions
+24 -313
View File
@@ -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>
);
}
+5 -3
View File
@@ -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}`),
+2
View File
@@ -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" /> },
+56 -11
View File
@@ -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 &amp; 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>
+147 -53
View File
@@ -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&apos;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 &amp; 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">&quot;{app.name}&quot;</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>
);
}
@@ -0,0 +1,314 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { AppSnapshot } from '@/types';
import { formatBytes } from '@/lib/format-utils';
import {
Camera,
ChevronDown,
Clock,
Database,
Download,
History,
Trash2,
XCircle,
} from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
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 BackupProgressBar({ progress, label }: { progress: number; label?: string }) {
const pct = Math.min(100, Math.max(0, progress));
return (
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-xs text-blue-700">
<span>{label || 'Creating database dump…'}</span>
<span className="font-semibold tabular-nums">{pct}%</span>
</div>
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
<div
className="h-2.5 rounded-full bg-blue-600 transition-all duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
export function DatabaseSnapshotsPanel({
serviceId,
isDeployed,
}: {
serviceId: string;
isDeployed: boolean;
}) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [showPanel, setShowPanel] = useState(false);
const prevInProgressRef = useRef<Set<string>>(new Set());
const { data: snapshots = [], isLoading } = useQuery<AppSnapshot[]>({
queryKey: ['snapshots', serviceId],
queryFn: () => api.get(`/snapshots/applications/${serviceId}`).then((r) => r.data),
enabled: showPanel,
refetchInterval: (query) => {
if (!showPanel) return false;
const list = query.state.data;
const hasInProgress = list?.some((s) => s.status === 'in_progress');
return hasInProgress ? 2000 : 10000;
},
});
const hasInProgress = snapshots.some((s) => s.status === 'in_progress');
useEffect(() => {
const inProgressIds = new Set(snapshots.filter((s) => s.status === 'in_progress').map((s) => s.id));
for (const snap of snapshots) {
if (
prevInProgressRef.current.has(snap.id) &&
!inProgressIds.has(snap.id) &&
snap.status === 'completed' &&
snap.dbDumpPath
) {
toast.success('Backup ready — you can download the dump now');
}
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
toast.error(snap.errorMessage || 'Backup failed');
}
}
prevInProgressRef.current = inProgressIds;
}, [snapshots]);
const createMutation = useMutation({
mutationFn: () =>
api.post(
`/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`,
),
onSuccess: () => {
setShowPanel(true);
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.info('Backup started — dump in progress');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
?.message;
const text = Array.isArray(msg) ? msg.join(', ') : msg;
toast.error(text || 'Failed to create backup');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/snapshots/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
toast.success('Backup deleted');
},
onError: () => toast.error('Failed to delete backup'),
});
const downloadSnapshotDb = (snapshotId: string) => {
const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/database`;
const token = localStorage.getItem('accessToken');
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => {
if (!r.ok) throw new Error('download failed');
return r.blob();
})
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
link.click();
URL.revokeObjectURL(link.href);
toast.success('Download started');
})
.catch(() => toast.error('Failed to download database dump'));
};
const handleDelete = async (snap: AppSnapshot) => {
const ok = await confirm({
title: 'Delete backup?',
message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteMutation.mutate(snap.id);
};
const handleNewBackup = () => {
setShowPanel(true);
createMutation.mutate();
};
return (
<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">
<History className="w-5 h-5" /> Snapshots
</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleNewBackup}
disabled={!isDeployed || createMutation.isPending || hasInProgress}
className="btn-secondary text-sm disabled:opacity-50"
title={
!isDeployed
? 'Deploy the service first'
: hasInProgress
? 'Wait for the current backup to finish'
: undefined
}
>
{createMutation.isPending || hasInProgress ? (
<>
<Clock className="w-3 h-3 inline animate-spin" /> Creating
</>
) : (
<>
<Camera className="w-3 h-3 inline" /> New Snapshot
</>
)}
</button>
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
{showPanel ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide
</>
) : (
<>
<History className="w-4 h-4 inline" /> Show
</>
)}
</button>
</div>
</div>
{showPanel && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
<p className="text-xs text-blue-700">
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> store a SQL dump you can
download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept;
oldest are removed automatically.
</p>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading backups</div>
) : snapshots.length === 0 ? (
<div className="text-center py-8">
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">No backups yet</p>
<p className="text-gray-400 text-xs mt-1">Click New backup to create your first database dump.</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{snapshots.map((snap) => (
<div
key={snap.id}
className={`border rounded-xl p-4 ${
snap.status === 'completed'
? 'border-gray-200 bg-white'
: snap.status === 'in_progress'
? 'border-blue-200 bg-blue-50'
: 'border-red-200 bg-red-50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Database backup'}</p>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}
>
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
</span>
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
{snap.status === 'in_progress' ? 'Dumping…' : snap.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString()}</p>
{snap.status === 'in_progress' && (
<BackupProgressBar
progress={snap.progress ?? 0}
label={
(snap.progress ?? 0) < 15
? 'Preparing dump…'
: (snap.progress ?? 0) < 90
? 'Exporting database…'
: 'Finalizing…'
}
/>
)}
{snap.status === 'completed' && snap.dbDumpPath && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Database className="w-3 h-3" /> Dump: {formatBytes(snap.dbDumpSize)}
</p>
)}
{snap.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={snap.errorMessage}>
<XCircle className="w-3 h-3 inline" /> {snap.errorMessage}
</p>
)}
</div>
{snap.status === 'completed' && snap.dbDumpPath && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => downloadSnapshotDb(snap.id)}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Download database dump"
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(snap)}
disabled={deleteMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
title="Delete backup"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
{snap.status === 'failed' && (
<button
type="button"
onClick={() => handleDelete(snap)}
disabled={deleteMutation.isPending}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0"
title="Remove failed backup"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 text-center">Maximum 10 backups are kept.</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,81 @@
'use client';
import { Database } from 'lucide-react';
export interface DatabaseWorkloadValues {
cpuRequest: string;
cpuLimit: string;
memoryRequest: string;
memoryLimit: string;
}
export function DatabaseWorkloadResources({
values,
onChange,
}: {
values: DatabaseWorkloadValues;
onChange: (patch: Partial<DatabaseWorkloadValues>) => void;
}) {
return (
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-800">Database resources</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CPU request</label>
<select
className="input-field text-sm"
value={values.cpuRequest}
onChange={(e) => onChange({ cpuRequest: e.target.value })}
>
<option value="50m">50m</option>
<option value="100m">100m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
<select
className="input-field text-sm"
value={values.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="250m">250m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory request</label>
<select
className="input-field text-sm"
value={values.memoryRequest}
onChange={(e) => onChange({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
<select
className="input-field text-sm"
value={values.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import type { Application } from '@/types';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { getAppsInProgress, isActiveBuildProgress } from '@/lib/deployment-progress';
import { filterApplications, filterManagedServices } from '@/lib/product-type';
import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal';
import { DeploymentProgressBar } from '@/components/deployment-progress-bar';
@@ -16,17 +17,29 @@ export function DeploymentProgressManager() {
const pathname = usePathname();
const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore();
const { data: apps = [] } = useQuery<Application[]>({
queryKey: ['applications'],
queryFn: () => api.get('/applications').then((r) => r.data),
const refetchWhileDeploying = (list: Application[] | undefined) =>
getAppsInProgress(list ?? []).length > 0 ? 3000 : false;
const { data: appsList = [] } = useQuery<Application[]>({
queryKey: ['applications', 'application'],
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
enabled: isAuthenticated,
refetchInterval: (query) => {
const list = query.state.data ?? [];
return getAppsInProgress(list).length > 0 ? 3000 : false;
},
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
});
const deployingApps = useMemo(() => getAppsInProgress(apps), [apps]);
const { data: servicesList = [] } = useQuery<Application[]>({
queryKey: ['applications', 'managed'],
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
enabled: isAuthenticated,
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
});
const allResources = useMemo(
() => [...filterApplications(appsList), ...filterManagedServices(servicesList)],
[appsList, servicesList],
);
const deployingApps = useMemo(() => getAppsInProgress(allResources), [allResources]);
const progressQueries = useQueries({
queries: deployingApps.map((app) => ({
@@ -48,7 +61,9 @@ export function DeploymentProgressManager() {
.filter(({ progress }) => isActiveBuildProgress(progress));
}, [deployingApps, progressQueries]);
const routeAppId = pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1];
const routeAppId =
pathname.match(/\/dashboard\/apps\/([^/]+)/)?.[1] ??
pathname.match(/\/dashboard\/services\/([^/]+)/)?.[1];
useEffect(() => {
if (activeItems.length === 0) {
@@ -0,0 +1,336 @@
'use client';
import { useRef, useState } from 'react';
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
export interface ManagedDatabaseFormState {
databaseType: DatabaseEngine;
dbVersion: string;
dbUsername: string;
dbPassword: string;
dbStorageSize: string;
}
const DB_OPTIONS: { value: DatabaseEngine; label: string; versions: string[] }[] = [
{ value: 'postgresql', label: 'PostgreSQL', versions: ['17', '16', '15', '14'] },
{ value: 'mysql', label: 'MySQL', versions: ['9.0', '8.4', '8.0', '5.7'] },
{ value: 'mariadb', label: 'MariaDB', versions: ['11.4', '11.3', '10.11', '10.6'] },
{ value: 'mongodb', label: 'MongoDB', versions: ['7.0', '6.0', '5.0'] },
];
function versionOptions(databaseType: DatabaseEngine) {
const opt = DB_OPTIONS.find((o) => o.value === databaseType);
return opt?.versions ?? ['16'];
}
function versionLabel(databaseType: DatabaseEngine, v: string) {
if (databaseType === 'postgresql') return `PostgreSQL ${v}`;
if (databaseType === 'mysql') return `MySQL ${v}`;
if (databaseType === 'mariadb') return `MariaDB ${v}`;
return `MongoDB ${v}`;
}
export function ManagedDatabaseConfig({
form,
onChange,
dbDumpFile,
onDbDumpFileChange,
}: {
form: ManagedDatabaseFormState;
onChange: (patch: Partial<ManagedDatabaseFormState>) => void;
dbDumpFile: File | null;
onDbDumpFileChange: (file: File | null) => void;
}) {
const dbDumpInputRef = useRef<HTMLInputElement>(null);
const [showDbPassword, setShowDbPassword] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const minDbGiFromRestoreDump = dbDumpFile ? minGiToFitFileBytes(dbDumpFile.size) : 1;
const dbGi = parseInt(form.dbStorageSize || '1', 10) || 1;
const acceptDump = (f: File) => {
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
toast.error('Allowed: .sql, .gz, .dump');
return;
}
if (f.size > 500 * 1024 * 1024) {
toast.error('Max 500MB');
return;
}
onDbDumpFileChange(f);
const suggested = Math.max(minGiToFitFileBytes(f.size), Math.ceil((f.size / ONE_GIB) * 3));
const cur = parseInt(form.dbStorageSize || '1', 10) || 1;
onChange({ dbStorageSize: String(Math.max(cur, suggested)) });
};
return (
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Database engine</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{DB_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() =>
onChange({
databaseType: opt.value,
dbVersion: opt.versions[0],
})
}
className={`p-3 rounded-xl border-2 text-center transition-colors ${
form.databaseType === opt.value
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Database
className={`w-6 h-6 mx-auto ${form.databaseType === opt.value ? 'text-primary-600' : 'text-gray-400'}`}
/>
<p className="mt-1 font-semibold text-sm text-gray-900">{opt.label}</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
<select
className="input-field max-w-xs"
value={form.dbVersion}
onChange={(e) => onChange({ dbVersion: e.target.value })}
>
{versionOptions(form.databaseType).map((v) => (
<option key={v} value={v}>
{versionLabel(form.databaseType, v)}
</option>
))}
</select>
</div>
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-800">Database credentials</h3>
<span className="text-xs text-gray-400">(optional auto-generated if empty)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Username</label>
<input
className="input-field"
placeholder="appuser"
value={form.dbUsername}
onChange={(e) => onChange({ dbUsername: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Password</label>
<div className="relative">
<input
className="input-field pr-20"
type={showDbPassword ? 'text' : 'password'}
placeholder="Auto-generated"
value={form.dbPassword}
onChange={(e) => onChange({ dbPassword: e.target.value })}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={() => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let pass = '';
for (let i = 0; i < 20; i++) pass += chars.charAt(Math.floor(Math.random() * chars.length));
onChange({ dbPassword: pass });
setShowDbPassword(true);
}}
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="Generate random password"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setShowDbPassword(!showDbPassword)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
{showDbPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
<p className="text-xs text-gray-400">
Used for internal cluster access. Use temporary or permanent external access below after deploy.
</p>
<div className="pt-2">
<label className="block text-xs text-gray-500 mb-2">Optional: upload DB dump to restore at creation</label>
<div
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f) acceptDump(f);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onClick={() => dbDumpInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-3 text-center cursor-pointer transition-colors ${
dbDumpFile
? 'border-blue-400 bg-blue-50'
: isDragging
? 'border-blue-400 bg-blue-50'
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
}`}
>
<input
ref={dbDumpInputRef}
type="file"
accept=".sql,.gz,.dump"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) acceptDump(f);
e.target.value = '';
}}
/>
{dbDumpFile ? (
<div className="flex items-center justify-between">
<div className="text-sm text-left">
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDbDumpFileChange(null);
}}
className="text-sm text-red-500"
>
Remove
</button>
</div>
) : (
<div>
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
<p className="text-xs text-gray-400">Optional Max 500MB .sql, .gz, .dump</p>
</div>
)}
</div>
</div>
<div className="pt-2">
<label className="block text-xs text-gray-500 mb-2">Database storage size</label>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => {
const current = parseInt(form.dbStorageSize || '1', 10);
if (current > minDbGiFromRestoreDump) {
onChange({ dbStorageSize: String(current - 1) });
}
}}
disabled={dbGi <= minDbGiFromRestoreDump}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={minDbGiFromRestoreDump}
max={100}
value={form.dbStorageSize || '1'}
onChange={(e) => {
const val = Math.max(
minDbGiFromRestoreDump,
Math.min(100, parseInt(e.target.value, 10) || minDbGiFromRestoreDump),
);
onChange({ dbStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.dbStorageSize || '1', 10);
if (current < 100) onChange({ dbStorageSize: String(current + 1) });
}}
disabled={dbGi >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{dbDumpFile && (
<span className="text-xs text-blue-500">
Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit)
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-400">
Minimum {minDbGiFromRestoreDump} GB
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} Only expansion allowed after creation
</p>
</div>
</div>
</div>
);
}
export function validateDbDumpStorage(
dbDumpFile: File | null,
dbStorageSizeGi: number,
): string | null {
if (!dbDumpFile) return null;
const need = minGiToFitFileBytes(dbDumpFile.size);
if (dbStorageSizeGi < need) {
return `Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbStorageSizeGi} GiB). Increase database storage, then try again.`;
}
return null;
}
export function RestoreStorageErrorModal({
open,
message,
onClose,
}: {
open: boolean;
message: string;
onClose: () => void;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
onClick={onClose}
role="presentation"
/>
<div className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full animate-modal-enter">
<div className="p-6 pb-0">
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Storage too small</h3>
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
</div>
<div className="flex items-center justify-end gap-3 p-6">
<button type="button" onClick={onClose} className="btn-primary">
OK
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,707 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
import {
ResourceUpgradeConfirmModal,
type UpgradeCostSummary,
} from '@/components/resource-upgrade-confirm-modal';
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
import { parseCpuToMillicores, parseMemoryToMi } from '@/lib/format-utils';
import { DatabaseWorkloadResources } from '@/components/database-workload-resources';
import { OptionalServiceResourceFields } from '@/components/optional-service-resource-fields';
import {
BarChart3,
CheckCircle,
ChevronDown,
Clock,
Database,
RefreshCw,
Scale,
Settings,
} from 'lucide-react';
type AppWithOptional = Application & { optionalServiceResources?: OptionalServiceResourcesMap };
interface StorageUsageSlice {
allocatedRaw: string;
allocatedGi: number;
usedGi: number;
availableGi: number;
usedPercent: number;
}
interface StorageUsageData {
database: StorageUsageSlice | null;
redisStorage?: StorageUsageSlice | null;
rabbitmqStorage?: StorageUsageSlice | null;
}
function workloadKey(app: Application): 'database' | 'redis' | 'rabbitmq' {
if (app.productType === 'managed_redis') return 'redis';
if (app.productType === 'managed_rabbitmq') return 'rabbitmq';
return 'database';
}
function workloadTitle(app: Application): string {
if (app.productType === 'managed_redis') return 'Redis';
if (app.productType === 'managed_rabbitmq') return 'RabbitMQ';
return 'Database';
}
type UpgradePayload = {
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
dbStorageSize?: string;
redisResources?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
rabbitmqResources?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
};
export function ManagedServiceResourcesPanel({
serviceId,
app,
isDeployed,
isStopped,
needsRenewal = false,
}: {
serviceId: string;
app: AppWithOptional;
isDeployed: boolean;
isStopped: boolean;
needsRenewal?: boolean;
}) {
const router = useRouter();
const queryClient = useQueryClient();
const dbFileInputRef = useRef<HTMLInputElement>(null);
const [showResources, setShowResources] = useState(false);
const [dbStorageSize, setDbStorageSize] = useState('1');
const [isDraggingDb, setIsDraggingDb] = useState(false);
const [dbRestoreLogs, setDbRestoreLogs] = useState<string | null>(null);
const [dbResources, setDbResources] = useState({
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '128Mi',
memoryLimit: '512Mi',
});
const [redisResources, setRedisResources] = useState(optionalDefaultsFromCatalog(undefined, 'redis'));
const [rabbitResources, setRabbitResources] = useState(optionalDefaultsFromCatalog(undefined, 'rabbitmq'));
const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
const [upgradeCostData, setUpgradeCostData] = useState<UpgradeCostSummary | null>(null);
const [pendingUpgradePayload, setPendingUpgradePayload] = useState<UpgradePayload | null>(null);
const isDatabase = app.productType === 'managed_database';
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
});
const { data: dbStorageData } = useQuery<{ currentSize: string }>({
queryKey: ['db-storage', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/db-storage`).then((r) => r.data),
enabled: isDatabase && app.databaseType !== 'none',
});
const { data: resourceUsage, isLoading: resourcesLoading } = useQuery<ResourceUsage>({
queryKey: ['resources', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/resources`).then((r) => r.data),
enabled: showResources && isDeployed,
refetchInterval: showResources && isDeployed ? 5000 : false,
});
const { data: storageUsage, isLoading: storageUsageLoading } = useQuery<StorageUsageData>({
queryKey: ['storage-usage', serviceId],
queryFn: () => api.get(`/applications/${serviceId}/storage`).then((r) => r.data),
enabled: showResources && isDeployed,
refetchInterval: showResources && isDeployed ? 15000 : false,
});
useEffect(() => {
if (dbStorageData?.currentSize) {
const n = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1;
setDbStorageSize(String(n));
}
}, [dbStorageData]);
useEffect(() => {
setDbResources({
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '128Mi',
memoryLimit: app.memoryLimit || '512Mi',
});
if (app.optionalServiceResources?.redis) setRedisResources(app.optionalServiceResources.redis);
if (app.optionalServiceResources?.rabbitmq) setRabbitResources(app.optionalServiceResources.rabbitmq);
}, [app]);
const directPatchResourcesMutation = useMutation({
mutationFn: (data: {
workload: 'database' | 'redis' | 'rabbitmq';
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
}) => api.patch(`/applications/${serviceId}/resources`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
toast.success('Resources updated');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to update resources');
},
});
const scaleMutation = useMutation({
mutationFn: (data: UpgradePayload) => api.post(`/billing/applications/${serviceId}/upgrade`, data),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
const paidAmount = res.data.paidAmount || 0;
if (paidAmount > 0) {
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
} else {
toast.success('Resources updated successfully');
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to upgrade resources');
},
});
const calculateUpgradeCostMutation = useMutation({
mutationFn: (data: UpgradePayload) =>
api.post(`/billing/applications/${serviceId}/upgrade/calculate`, data),
onSuccess: (res) => {
setUpgradeCostData(res.data);
setShowUpgradeConfirm(true);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to calculate upgrade cost');
},
});
const createUpgradeInvoiceMutation = useMutation({
mutationFn: (data: UpgradePayload) =>
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.');
queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
router.push(`/dashboard/invoices?invoice=${invoice.id}`);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to create upgrade invoice');
},
});
const resizeDbMutation = useMutation({
mutationFn: (size: string) => api.patch(`/applications/${serviceId}/db-storage`, { size }),
onSuccess: (res) => {
if (res.data.success) {
toast.success(res.data.message || 'Storage expanded');
queryClient.invalidateQueries({ queryKey: ['db-storage', serviceId] });
queryClient.invalidateQueries({ queryKey: ['storage-usage', serviceId] });
} else {
toast.error(res.data.message || 'Failed to expand storage');
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to resize storage');
},
});
const dbUploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/applications/${serviceId}/db-upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: (res) => {
const data = res.data;
setDbRestoreLogs(data.logs || null);
if (data.success) toast.success('Database restored successfully');
else toast.error(data.message || 'Database restore failed');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to upload database dump');
setDbRestoreLogs(null);
},
});
const handleDbFileUpload = useCallback(
(file: File) => {
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
toast.error('Please upload a .sql, .dump, or .gz file');
return;
}
if (file.size > 500 * 1024 * 1024) {
toast.error('File size must be less than 500MB');
return;
}
setDbRestoreLogs(null);
dbUploadMutation.mutate(file);
},
[dbUploadMutation],
);
const buildUpgradePayload = useCallback((): UpgradePayload => {
if (app.productType === 'managed_database') {
return { ...dbResources };
}
if (app.productType === 'managed_redis') {
return {
redisResources: {
cpuRequest: redisResources.cpuRequest,
cpuLimit: redisResources.cpuLimit,
memoryRequest: redisResources.memoryRequest,
memoryLimit: redisResources.memoryLimit,
storageGi: redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
};
}
return {
rabbitmqResources: {
cpuRequest: rabbitResources.cpuRequest,
cpuLimit: rabbitResources.cpuLimit,
memoryRequest: rabbitResources.memoryRequest,
memoryLimit: rabbitResources.memoryLimit,
storageGi: rabbitResources.storageGi ?? app.optionalServiceResources?.rabbitmq?.storageGi ?? 2,
},
};
}, [app, dbResources, redisResources, rabbitResources]);
const applyResources = () => {
if (needsRenewal) {
toast.warn('Renew the service before changing resources');
return;
}
const payload = buildUpgradePayload();
const w = workloadKey(app);
if (!app.billingCycle) {
if (w === 'database') {
directPatchResourcesMutation.mutate({ workload: 'database', ...dbResources });
} else if (w === 'redis') {
directPatchResourcesMutation.mutate({
workload: 'redis',
cpuRequest: redisResources.cpuRequest,
cpuLimit: redisResources.cpuLimit,
memoryRequest: redisResources.memoryRequest,
memoryLimit: redisResources.memoryLimit,
});
} else {
directPatchResourcesMutation.mutate({
workload: 'rabbitmq',
cpuRequest: rabbitResources.cpuRequest,
cpuLimit: rabbitResources.cpuLimit,
memoryRequest: rabbitResources.memoryRequest,
memoryLimit: rabbitResources.memoryLimit,
});
}
return;
}
setPendingUpgradePayload(payload);
calculateUpgradeCostMutation.mutate(payload);
};
const confirmUpgrade = () => {
if (!pendingUpgradePayload || !upgradeCostData) return;
if (upgradeCostData.proratedAmount > 0) {
createUpgradeInvoiceMutation.mutate(pendingUpgradePayload);
} else {
scaleMutation.mutate(pendingUpgradePayload);
}
};
const handleExpandStorage = () => {
if (needsRenewal) {
toast.warn('Renew the service before expanding storage');
return;
}
const newGi = parseInt(dbStorageSize, 10);
if (newGi <= currentDbGi) {
toast.warn('New size must be larger than current allocation');
return;
}
const newSize = `${newGi}Gi`;
const payload: UpgradePayload = { dbStorageSize: newSize };
if (!app.billingCycle) {
resizeDbMutation.mutate(newSize);
return;
}
setPendingUpgradePayload(payload);
calculateUpgradeCostMutation.mutate(payload);
};
const resourcesPending =
directPatchResourcesMutation.isPending ||
scaleMutation.isPending ||
calculateUpgradeCostMutation.isPending ||
createUpgradeInvoiceMutation.isPending;
const currentDbGi =
parseInt((dbStorageData?.currentSize || app.dbStorageSize || '1Gi').replace('Gi', ''), 10) || 1;
const metricsWorkload =
resourceUsage?.workloads?.find((w) => w.key === workloadKey(app)) ||
(resourceUsage?.configured
? {
key: workloadKey(app),
title: workloadTitle(app),
deploymentName: `${app.name}-${workloadKey(app) === 'database' ? 'db' : workloadKey(app)}`,
configured: resourceUsage.configured,
pods: resourceUsage.pods,
metrics: resourceUsage.metrics,
}
: null);
const storageSlice =
isDatabase
? storageUsage?.database
: app.productType === 'managed_redis'
? storageUsage?.redisStorage
: storageUsage?.rabbitmqStorage;
return (
<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">
<BarChart3 className="w-5 h-5" /> Resources &amp; Scaling
</h2>
<button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm">
{showResources ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide
</>
) : (
<>
<BarChart3 className="w-4 h-4 inline" /> Monitor
</>
)}
</button>
</div>
{showResources && (
<div className="space-y-6">
{!isDeployed ? (
<p className="text-sm text-gray-500 text-center py-6">Deploy the service to view metrics and adjust resources.</p>
) : resourcesLoading ? (
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics</div>
) : metricsWorkload ? (
<div className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">{metricsWorkload.title}</h3>
<span className="text-[11px] text-gray-400 font-mono">{metricsWorkload.deploymentName}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
<p className="text-lg font-bold text-blue-800">
{metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas}
</p>
</div>
<div className="bg-green-50 rounded-lg p-3">
<p className="text-[10px] text-green-600 font-medium">Pods</p>
<p className="text-lg font-bold text-green-800">{metricsWorkload.pods.length}</p>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
<p className="text-lg font-bold text-purple-800 flex justify-center">
{metricsWorkload.metrics.length > 0 ? (
<CheckCircle className="w-5 h-5 text-purple-700" />
) : (
<Clock className="w-5 h-5 text-purple-400" />
)}
</p>
</div>
</div>
{metricsWorkload.metrics.length > 0 && (
<div className="space-y-2">
{metricsWorkload.metrics.map((metric) => {
const cpuUsed = parseCpuToMillicores(metric.cpu);
const cpuLimit = parseCpuToMillicores(metricsWorkload.configured.cpuLimit);
const cpuPercent = cpuLimit > 0 ? Math.min((cpuUsed / cpuLimit) * 100, 100) : 0;
const memUsed = parseMemoryToMi(metric.memory);
const memLimit = parseMemoryToMi(metricsWorkload.configured.memoryLimit);
const memPercent = memLimit > 0 ? Math.min((memUsed / memLimit) * 100, 100) : 0;
return (
<div key={metric.name} className="bg-white rounded-lg p-3 border border-gray-100 text-xs">
<p className="font-mono text-gray-600 truncate mb-2">{metric.name}</p>
<div className="space-y-1">
<div className="flex justify-between text-gray-500">
<span>CPU</span>
<span>
{cpuPercent.toFixed(0)}% ({cpuUsed.toFixed(0)}m / {cpuLimit.toFixed(0)}m)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${cpuPercent}%` }}
/>
</div>
<div className="flex justify-between text-gray-500">
<span>Memory</span>
<span>
{memPercent.toFixed(0)}% ({memUsed.toFixed(0)} / {memLimit.toFixed(0)} Mi)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${memPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`}
style={{ width: `${memPercent}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4">
{isStopped ? 'Service is stopped.' : 'No resource metrics yet.'}
</p>
)}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Database className="w-4 h-4" /> Storage
</h3>
{storageUsageLoading ? (
<p className="text-sm text-gray-400">Loading storage</p>
) : storageSlice ? (
<div className="bg-gray-50 rounded-xl p-4 space-y-3">
<div className="flex justify-between text-xs text-gray-600">
<span>Used {storageSlice.usedGi.toFixed(2)} GiB</span>
<span>Allocated {storageSlice.allocatedGi.toFixed(1)} GiB</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full ${storageSlice.usedPercent > 80 ? 'bg-red-500' : storageSlice.usedPercent > 50 ? 'bg-yellow-500' : 'bg-blue-500'}`}
style={{ width: `${Math.min(storageSlice.usedPercent, 100)}%` }}
/>
</div>
{isDatabase && (
<div className="flex flex-wrap items-center gap-3 pt-2 border-t border-gray-200">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const c = parseInt(dbStorageSize, 10);
if (c > currentDbGi + 1) setDbStorageSize(String(c - 1));
}}
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
>
</button>
<input
type="number"
min={1}
max={100}
value={dbStorageSize}
onChange={(e) =>
setDbStorageSize(String(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1))))
}
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold"
/>
<button
type="button"
onClick={() => {
const c = parseInt(dbStorageSize, 10);
if (c < 100) setDbStorageSize(String(c + 1));
}}
className="px-3 py-1.5 bg-gray-100 font-bold text-sm"
>
+
</button>
</div>
<span className="text-sm text-gray-600">GB</span>
<button
type="button"
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
disabled={
resourcesPending ||
!isDeployed ||
parseInt(dbStorageSize, 10) <= currentDbGi
}
onClick={handleExpandStorage}
>
{resizeDbMutation.isPending || calculateUpgradeCostMutation.isPending
? 'Expanding…'
: 'Expand'}
</button>
<p className="text-xs text-gray-400 w-full">
Only expansion is allowed.
{app.billingCycle
? ' Additional storage is charged for the remaining billing period.'
: ''}
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400">Storage metrics unavailable.</p>
)}
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Settings className="w-4 h-4" /> Adjust CPU / memory
</h3>
{app.productType === 'managed_database' && (
<DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} />
)}
{app.productType === 'managed_redis' && (
<OptionalServiceResourceFields
title="Redis resources"
accentClass="text-red-500"
borderClass="border-red-200"
bgClass="bg-red-50/30"
config={redisResources}
onChange={(p) => setRedisResources((c) => ({ ...c, ...p }))}
/>
)}
{app.productType === 'managed_rabbitmq' && (
<OptionalServiceResourceFields
title="RabbitMQ resources"
accentClass="text-orange-500"
borderClass="border-orange-200"
bgClass="bg-orange-50/30"
config={rabbitResources}
onChange={(p) => setRabbitResources((c) => ({ ...c, ...p }))}
/>
)}
{app.billingCycle && (
<p className="text-xs text-gray-500 mt-3">
Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as
applications.
</p>
)}
<button
type="button"
className="btn-primary text-sm mt-4 disabled:opacity-50"
disabled={resourcesPending || needsRenewal || !isDeployed}
onClick={applyResources}
>
{resourcesPending ? (
<>
<Clock className="w-3 h-3 inline animate-spin" /> Applying
</>
) : (
<>
<RefreshCw className="w-3 h-3 inline" /> Apply changes
</>
)}
</button>
</div>
{isDatabase && (
<div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
<Scale className="w-4 h-4" /> Restore database dump
</h3>
<div
onDrop={(e) => {
e.preventDefault();
setIsDraggingDb(false);
const file = e.dataTransfer.files[0];
if (file) handleDbFileUpload(file);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDraggingDb(true);
}}
onDragLeave={() => setIsDraggingDb(false)}
onClick={() => dbFileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${
isDraggingDb ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
} ${dbUploadMutation.isPending ? 'pointer-events-none opacity-60' : ''}`}
>
<input
ref={dbFileInputRef}
type="file"
accept=".sql,.gz,.dump"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleDbFileUpload(file);
e.target.value = '';
}}
/>
{dbUploadMutation.isPending ? (
<p className="text-sm text-gray-700">Restoring database</p>
) : (
<>
<Database className="w-8 h-8 mx-auto text-gray-400 mb-2" />
<p className="text-sm font-medium text-gray-700">Upload SQL dump to restore</p>
<p className="text-xs text-gray-500 mt-1">.sql, .gz, or .dump max 500MB</p>
</>
)}
</div>
{dbRestoreLogs && (
<pre className="mt-3 bg-gray-900 text-green-400 p-4 rounded-xl text-xs font-mono max-h-[240px] overflow-auto whitespace-pre-wrap">
{dbRestoreLogs}
</pre>
)}
</div>
)}
</div>
)}
<ResourceUpgradeConfirmModal
open={showUpgradeConfirm}
upgradeCostData={upgradeCostData}
walletBalance={walletData?.balance}
isPending={scaleMutation.isPending || createUpgradeInvoiceMutation.isPending}
onCancel={() => {
setShowUpgradeConfirm(false);
setUpgradeCostData(null);
setPendingUpgradePayload(null);
}}
onConfirm={confirmUpgrade}
/>
</div>
);
}
@@ -0,0 +1,95 @@
'use client';
import { Server } from 'lucide-react';
import type { OptionalServiceResourceConfig } from '@/types';
export function OptionalServiceResourceFields({
title,
accentClass,
borderClass,
bgClass,
config,
readOnly,
onChange,
}: {
title: string;
accentClass: string;
borderClass: string;
bgClass: string;
config: OptionalServiceResourceConfig;
readOnly?: boolean;
onChange: (patch: Partial<OptionalServiceResourceConfig>) => void;
}) {
const storageStr = String(config.storageGi);
const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) });
return (
<div className={`rounded-xl p-5 border-2 ${borderClass} ${bgClass}`}>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Server className={`w-5 h-5 ${accentClass}`} />
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CPU limit</label>
<select
className="input-field text-sm"
disabled={readOnly}
value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="200m">200m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Memory limit</label>
<select
className="input-field text-sm"
disabled={readOnly}
value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1">Storage (GB)</label>
<div className="flex items-center gap-2">
<button
type="button"
disabled={readOnly}
onClick={() => setStorage(parseInt(storageStr, 10) - 1)}
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
>
</button>
<input
type="number"
min={0}
max={100}
disabled={readOnly}
value={storageStr}
onChange={(e) => setStorage(parseInt(e.target.value, 10) || 0)}
className="w-16 text-center input-field py-1.5"
/>
<button
type="button"
disabled={readOnly}
onClick={() => setStorage(parseInt(storageStr, 10) + 1)}
className="px-3 py-1.5 bg-gray-100 rounded-lg font-bold text-sm disabled:opacity-30"
>
+
</button>
<span className="text-sm text-gray-600">GB</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import { AlertTriangle, CheckCircle, Clock, CreditCard, Wallet } from 'lucide-react';
export type UpgradeCostSummary = {
proratedAmount: number;
remainingHours: number;
currentCost: { hourly: number; monthly: number; yearly: number };
newCost: { hourly: number; monthly: number; yearly: number };
};
export function ResourceUpgradeConfirmModal({
open,
upgradeCostData,
walletBalance,
isPending,
onCancel,
onConfirm,
}: {
open: boolean;
upgradeCostData: UpgradeCostSummary | null;
walletBalance?: number;
isPending: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
if (!open || !upgradeCostData) return null;
const needsPay = upgradeCostData.proratedAmount > 0;
const walletShort =
needsPay && walletBalance != null && upgradeCostData.proratedAmount > walletBalance;
return (
<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 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirm Resource Upgrade</h2>
<p className="text-sm text-gray-500 mb-6">
{needsPay
? 'This upgrade requires payment for the remaining billing period.'
: 'No additional cost for this change.'}
</p>
<div className="bg-gray-50 rounded-xl p-4 mb-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Current hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.currentCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">New hourly cost</span>
<span className="text-sm font-medium text-gray-900">
{upgradeCostData.newCost.hourly.toLocaleString()} Toman/hour
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Remaining hours in period</span>
<span className="text-sm font-medium text-gray-900">{upgradeCostData.remainingHours} hours</span>
</div>
<div className="border-t pt-3 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Prorated amount to pay</span>
<span className="text-lg font-bold text-primary-600">
{upgradeCostData.proratedAmount.toLocaleString()} Toman
</span>
</div>
</div>
<div className="bg-blue-50 rounded-xl p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Wallet className="w-5 h-5 text-blue-500" />
<span className="text-sm text-blue-700">Wallet Balance</span>
</div>
<span className="text-lg font-bold text-blue-900">
{walletBalance?.toLocaleString() ?? 0} Toman
</span>
</div>
{walletShort && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
<p className="text-sm text-amber-700">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Wallet is short by{' '}
{(upgradeCostData.proratedAmount - (walletBalance ?? 0)).toLocaleString()} Toman. You can pay the
delta by gateway on the invoice page.
</p>
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl font-medium text-gray-700 hover:bg-gray-50 transition-all"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isPending}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-medium hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isPending ? (
<>
<Clock className="w-4 h-4 animate-spin" /> Applying
</>
) : needsPay ? (
<>
<CreditCard className="w-4 h-4" /> Create Invoice & Pay
</>
) : (
<>
<CheckCircle className="w-4 h-4" /> Apply Changes
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,290 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
export function ServiceExternalAccessPanel({
appId,
app,
}: {
appId: string;
app: Application;
}) {
const queryClient = useQueryClient();
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60);
const [accessPersistent, setAccessPersistent] = useState(false);
const [showAccessSecret, setShowAccessSecret] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [accessNow, setAccessNow] = useState(() => Date.now());
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
const hasDb =
(app.databaseType && app.databaseType !== 'none') || app.productType === 'managed_database';
if (hasDb) accessTargetOptions.push({ value: 'database', label: 'Database' });
if (app.enableRedis || app.productType === 'managed_redis') {
accessTargetOptions.push({ value: 'redis', label: 'Redis' });
}
if (app.enableRabbitmq || app.productType === 'managed_rabbitmq') {
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, app.productType]);
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' && !g.persistent)) return;
const t = setInterval(() => setAccessNow(Date.now()), 1000);
return () => clearInterval(t);
}, [accessGrants]);
const createAccessMutation = useMutation({
mutationFn: () =>
api
.post(`/applications/${appId}/access`, {
target: accessTarget,
persistent: accessPersistent,
...(accessPersistent ? {} : { durationMinutes: accessDuration }),
})
.then((r) => r.data),
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success(accessPersistent ? 'Permanent external access enabled' : 'Temporary external access enabled');
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to enable access');
},
});
const revokeAccessMutation = useMutation({
mutationFn: (grantId: string) => api.delete(`/applications/${appId}/access/${grantId}`),
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success('Access revoked');
},
onError: () => toast.error('Failed to revoke access'),
});
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const accessTargetLabel = (target: ServiceAccessTarget) =>
accessTargetOptions.find((o) => o.value === target)?.label || target;
const formatAccessCountdown = (grant: ServiceAccessGrant) => {
if (grant.persistent) return 'Permanent (until revoked)';
const ms = new Date(grant.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`;
};
if (!hasAccessTargets) return null;
return (
<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" /> 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 session ends or you
revoke it. Use short durations for temporary access; permanent keeps the port open until revoked.
</p>
{!app.latestImageTag ? (
<p className="text-sm text-gray-500">Deploy the service first to enable external access.</p>
) : (
<>
<div className="bg-gray-50 rounded-xl p-4 mb-4 space-y-4">
<div className="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-field 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-[200px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Access mode</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAccessPersistent(false)}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
!accessPersistent
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Temporary
</button>
<button
type="button"
onClick={() => setAccessPersistent(true)}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border transition-colors ${
accessPersistent
? 'bg-amber-600 text-white border-amber-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Always open
</button>
</div>
</div>
</div>
{!accessPersistent && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
<div className="flex gap-2 flex-wrap">
{[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>
)}
{accessPersistent && (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
The port stays exposed until you click Revoke. Only use this when you need a stable external endpoint.
</p>
)}
<button
type="button"
onClick={() => createAccessMutation.mutate()}
disabled={createAccessMutation.isPending}
className="btn-primary text-sm"
>
{createAccessMutation.isPending ? 'Opening…' : accessPersistent ? 'Open port permanently' : '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>
{grant.persistent && (
<span className="ml-2 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
Always open
</span>
)}
<span className="ml-2 text-xs text-gray-500">{formatAccessCountdown(grant)}</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"
>
{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>
);
}
@@ -0,0 +1,165 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { FileText, ChevronDown, Monitor, Hammer, Pin } from 'lucide-react';
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',
};
export function WorkloadLogsPanel({
appId,
showBuildLogs = true,
isRunning = false,
isStopped = false,
emptyPodMessage,
}: {
appId: string;
showBuildLogs?: boolean;
isRunning?: boolean;
isStopped?: boolean;
emptyPodMessage?: string;
}) {
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const logsEndRef = useRef<HTMLPreElement>(null);
useEffect(() => {
if (!showBuildLogs && logTab === 'build') {
setLogTab('pod');
}
}, [showBuildLogs, logTab]);
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: showBuildLogs && showLogs && logTab === 'build',
refetchInterval: showBuildLogs && showLogs && logTab === 'build' ? 5000 : false,
});
const podPlaceholder =
emptyPodMessage ||
(isRunning
? 'Loading logs...'
: isStopped
? 'Service is stopped. Start it to see logs.'
: 'Waiting for workload pods to be ready...');
return (
<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>
)}
{showBuildLogs && 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 type="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">
{showBuildLogs ? (
<div className="flex gap-1 bg-gray-100 rounded-xl p-1">
<button
type="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
type="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>
) : (
<p className="text-xs text-gray-500">Workload pod output (no image build for this service).</p>
)}
{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 || podPlaceholder}
</pre>
)}
{showBuildLogs && 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>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function formatBytes(bytes?: number): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function parseCpuToMillicores(cpu: string): number {
if (!cpu) return 0;
if (cpu.endsWith('m')) return parseFloat(cpu);
return parseFloat(cpu) * 1000;
}
export function parseMemoryToMi(mem: string): number {
if (!mem) return 0;
if (mem.endsWith('Gi')) return parseFloat(mem) * 1024;
if (mem.endsWith('Mi')) return parseFloat(mem);
if (mem.endsWith('Ki')) return parseFloat(mem) / 1024;
return parseFloat(mem);
}
@@ -0,0 +1,53 @@
import type {
OptionalServiceResourceConfig,
OptionalServiceResourcesMap,
PricingCatalog,
} from '@/types';
export type OptionalServiceKey = keyof OptionalServiceResourcesMap;
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
redis: {
cpuRequest: '50m',
cpuLimit: '200m',
memoryRequest: '64Mi',
memoryLimit: '256Mi',
storageGi: 1,
},
rabbitmq: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
storageGi: 2,
},
};
export function optionalDefaultsFromCatalog(
catalog: PricingCatalog | undefined,
service: OptionalServiceKey,
): OptionalServiceResourceConfig {
const profile = catalog?.optionalServices?.[service]?.profile;
const fallback = FALLBACK_OPTIONAL_RESOURCES[service];
if (!profile) return { ...fallback };
return {
cpuRequest: profile.cpuRequest || fallback.cpuRequest,
cpuLimit: profile.cpuLimit || fallback.cpuLimit,
memoryRequest: profile.memoryRequest || fallback.memoryRequest,
memoryLimit: profile.memoryLimit || fallback.memoryLimit,
storageGi: profile.storageGi ?? fallback.storageGi,
};
}
export function managedServiceTypeLabel(productType?: string): string {
switch (productType) {
case 'managed_database':
return 'Managed Database';
case 'managed_redis':
return 'Managed Redis';
case 'managed_rabbitmq':
return 'Managed RabbitMQ';
default:
return 'Service';
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { Application, ProductType } from '@/types';
const MANAGED_PRODUCT_TYPES: ProductType[] = [
'managed_database',
'managed_redis',
'managed_rabbitmq',
];
export function isManagedProduct(app: Pick<Application, 'productType'>): boolean {
const t = app.productType;
return !!t && MANAGED_PRODUCT_TYPES.includes(t);
}
export function isApplicationProduct(app: Pick<Application, 'productType'>): boolean {
return !isManagedProduct(app);
}
export function filterApplications(apps: Application[]): Application[] {
return apps.filter(isApplicationProduct);
}
export function filterManagedServices(apps: Application[]): Application[] {
return apps.filter(isManagedProduct);
}
+6
View File
@@ -0,0 +1,6 @@
/** Kubernetes-style Gi (1024³ bytes), aligned with wizard “GB” fields */
export const ONE_GIB = 1024 ** 3;
export function minGiToFitFileBytes(bytes: number): number {
return Math.max(1, Math.ceil(bytes / ONE_GIB));
}
+11 -1
View File
@@ -9,9 +9,16 @@ export interface User {
createdAt: string;
}
export type ProductType =
| 'application'
| 'managed_database'
| 'managed_redis'
| 'managed_rabbitmq';
export interface Application {
id: string;
name: string;
productType?: ProductType;
description?: string;
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
@@ -114,6 +121,7 @@ export interface ServiceAccessGrant {
port: number;
targetPort: number;
expiresAt: string;
persistent?: boolean;
status: 'active' | 'expired' | 'revoked';
connection: ServiceAccessConnection;
}
@@ -211,8 +219,9 @@ export interface AuthResponse {
export interface CreateApplicationDto {
name: string;
productType?: ProductType;
description?: string;
runtime: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
runtime?: 'nodejs' | 'laravel' | 'wordpress' | 'go' | 'php' | 'python' | 'django' | 'dotnet';
databaseType: 'mysql' | 'postgresql' | 'mongodb' | 'mariadb' | 'none';
runtimeVersion?: string;
phpVersion?: string;
@@ -660,6 +669,7 @@ export interface AppSnapshot {
dbDumpSize?: number;
wpContentSize?: number;
errorMessage?: string;
progress?: number;
applicationId: string;
createdBy: string;
createdAt: string;