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:
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Application } from '@/types';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { filterManagedServices } from '@/lib/product-type';
|
||||
|
||||
const lifecycleColors: Record<string, string> = {
|
||||
active: 'text-green-600 bg-green-50',
|
||||
suspended: 'text-amber-700 bg-amber-50',
|
||||
pending_deletion: 'text-red-700 bg-red-50',
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
const lifecycleLabels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
suspended: 'Suspended — Unpaid',
|
||||
pending_deletion: 'Pending Deletion',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
|
||||
function serviceSubtitle(app: Application): string {
|
||||
if (app.productType === 'managed_database') {
|
||||
return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_redis') {
|
||||
return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`;
|
||||
}
|
||||
if (app.productType === 'managed_rabbitmq') {
|
||||
return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`;
|
||||
}
|
||||
return app.databaseType !== 'none' ? app.databaseType : '—';
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
if (!expiresAt) return { text: '—', urgent: false };
|
||||
const diff = new Date(expiresAt).getTime() - Date.now();
|
||||
if (diff <= 0) return { text: 'Expired', urgent: true };
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return { text: `${days}d ${hours % 24}h remaining`, urgent: days < 3 };
|
||||
if (hours > 0) return { text: `${hours}h remaining`, urgent: hours < 6 };
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return { text: `${mins}m remaining`, urgent: true };
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: servicesRaw = [], isLoading } = useQuery<Application[]>({
|
||||
queryKey: ['applications', 'managed'],
|
||||
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
||||
});
|
||||
const services = filterManagedServices(servicesRaw);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
toast.success('Service deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Service deleted');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to delete service'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div className="skeleton h-8 w-56" />
|
||||
<div className="skeleton h-10 w-40 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-11 h-11 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-36" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Databases & Services</h1>
|
||||
<p className="page-subtitle">
|
||||
Standalone databases, Redis, and RabbitMQ — {services.length} service{services.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/services/new" className="btn-primary">
|
||||
<Plus className="w-4 h-4 mr-1 inline" /> New Service
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Database className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No managed services yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">
|
||||
Provision a database, Redis, or RabbitMQ without deploying a full application.
|
||||
</p>
|
||||
<Link href="/dashboard/services/new" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<Plus className="w-4 h-4" /> Create your first service
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{services.map((svc) => {
|
||||
const lifecycle = svc.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(svc.planExpiresAt);
|
||||
const latestStatus = svc.deployments?.[0]?.status || 'pending';
|
||||
return (
|
||||
<div
|
||||
key={svc.id}
|
||||
className={`card-hover flex flex-col sm:flex-row sm:items-center gap-4 ${
|
||||
lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : ''
|
||||
} ${lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''}`}
|
||||
>
|
||||
<Link href={`/dashboard/services/${svc.id}`} className="flex items-center gap-3 flex-1 min-w-0 group">
|
||||
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Database className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-gray-900 group-hover:text-primary-600 truncate">{svc.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
||||
{latestStatus}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
</span>
|
||||
<span className={`text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
{expiry.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm text-red-600"
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
message: `Permanently delete "${svc.name}"? This cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(svc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user