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
@@ -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>
);
}