abbe821d91
Show minutes and local expiry for resource credits; add shared delete hook with row/card loading overlays, detail-page deleting modal, and disabled controls to prevent double-delete. Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import Link from 'next/link';
|
|
import api from '@/lib/api';
|
|
import type { Application } from '@/types';
|
|
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
|
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
|
import { useConfirm } from '@/components/confirm-modal';
|
|
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
|
import { DeletingCardOverlay, deletingResourceMessage } from '@/components/deleting-overlay';
|
|
import { filterManagedServices } from '@/lib/product-type';
|
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
|
|
|
const lifecycleColors: Record<string, string> = {
|
|
active: 'text-green-600 bg-green-50',
|
|
suspended: 'text-amber-700 bg-amber-50',
|
|
pending_deletion: 'text-red-700 bg-red-50',
|
|
deleted: 'text-gray-500 bg-gray-100',
|
|
};
|
|
|
|
const lifecycleLabels: Record<string, string> = {
|
|
active: 'Active',
|
|
suspended: 'Suspended — Unpaid',
|
|
pending_deletion: 'Pending Deletion',
|
|
deleted: 'Deleted',
|
|
};
|
|
|
|
function serviceSubtitle(app: Application): string {
|
|
if (app.productType === 'managed_database') {
|
|
return `${app.databaseType}${app.dbVersion ? ` v${app.dbVersion}` : ''}`;
|
|
}
|
|
if (app.productType === 'managed_redis') {
|
|
return `Redis${app.redisVersion ? ` v${app.redisVersion}` : ''}`;
|
|
}
|
|
if (app.productType === 'managed_rabbitmq') {
|
|
return `RabbitMQ${app.rabbitmqVersion ? ` v${app.rabbitmqVersion}` : ''}`;
|
|
}
|
|
return app.databaseType !== 'none' ? app.databaseType : '—';
|
|
}
|
|
|
|
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
|
if (!expiresAt) return { text: '—', urgent: false };
|
|
const diff = new Date(expiresAt).getTime() - Date.now();
|
|
if (diff <= 0) return { text: 'Expired', urgent: true };
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(hours / 24);
|
|
if (days > 0) return { text: `${days}d ${hours % 24}h remaining`, urgent: days < 3 };
|
|
if (hours > 0) return { text: `${hours}h remaining`, urgent: hours < 6 };
|
|
const mins = Math.floor(diff / 60000);
|
|
return { text: `${mins}m remaining`, urgent: true };
|
|
}
|
|
|
|
export default function ServicesPage() {
|
|
const confirm = useConfirm();
|
|
|
|
const { data: servicesRaw = [], isLoading } = useQuery<Application[]>({
|
|
queryKey: ['applications', 'managed'],
|
|
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
|
});
|
|
const services = filterManagedServices(servicesRaw);
|
|
|
|
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
|
invalidateKeys: [['applications', 'managed']],
|
|
successMessage: 'Service deleted',
|
|
successWithCreditMessage: 'Service deleted. Prepaid resources are on your dashboard.',
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="page-header">
|
|
<div className="skeleton h-8 w-56" />
|
|
<div className="skeleton h-10 w-40 rounded-xl" />
|
|
</div>
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="card flex items-center gap-4">
|
|
<div className="skeleton w-11 h-11 rounded-xl" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="skeleton h-4 w-36" />
|
|
<div className="skeleton h-3 w-48" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="page-header">
|
|
<div>
|
|
<h1 className="page-title">Databases & Services</h1>
|
|
<p className="page-subtitle">
|
|
Standalone databases, Redis, and RabbitMQ — {services.length} service{services.length !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
<Link href="/dashboard/services/new" className="btn-primary">
|
|
<Plus className="w-4 h-4 mr-1 inline" /> New Service
|
|
</Link>
|
|
</div>
|
|
|
|
{services.length === 0 ? (
|
|
<div className="card text-center py-16">
|
|
<Database className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
|
<p className="text-gray-600 text-lg font-medium">No managed services yet</p>
|
|
<p className="text-gray-400 mt-1 text-sm">
|
|
Provision a database, Redis, or RabbitMQ without deploying a full application.
|
|
</p>
|
|
<Link href="/dashboard/services/new" className="btn-primary mt-6 inline-flex items-center gap-1">
|
|
<Plus className="w-4 h-4" /> Create your first service
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{services.map((svc) => {
|
|
const lifecycle = svc.lifecycleStatus || 'active';
|
|
const expiry = formatExpiry(svc.planExpiresAt);
|
|
const latestStatus = svc.deployments?.[0]?.status || 'pending';
|
|
const cardDeleting = isDeleting(svc.id);
|
|
return (
|
|
<div
|
|
key={svc.id}
|
|
className={`relative card-hover flex flex-col sm:flex-row sm:items-center gap-4 ${
|
|
lifecycle === 'suspended' ? 'border-l-4 border-l-amber-400' : ''
|
|
} ${lifecycle === 'pending_deletion' ? 'border-l-4 border-l-red-400' : ''} ${cardDeleting ? 'bg-gray-50' : ''}`}
|
|
>
|
|
{cardDeleting && (
|
|
<DeletingCardOverlay message={deletingResourceMessage('service', svc.name)} />
|
|
)}
|
|
<Link href={`/dashboard/services/${svc.id}`} className="flex items-center gap-3 flex-1 min-w-0 group">
|
|
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
|
|
<Database className="w-5 h-5 text-indigo-600" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="font-semibold text-gray-900 group-hover:text-primary-600 truncate">{svc.name}</p>
|
|
<p className="text-sm text-gray-500">
|
|
{managedServiceTypeLabel(svc.productType)} · {serviceSubtitle(svc)}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
|
<span className={`badge ${latestStatus === 'running' ? 'badge-green' : 'badge-yellow'}`}>
|
|
{latestStatus}
|
|
</span>
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
|
|
lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'
|
|
}`}
|
|
>
|
|
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
|
{lifecycleLabels[lifecycle] || lifecycle}
|
|
</span>
|
|
<span className={`text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
|
{expiry.text}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn-ghost text-sm text-red-600 disabled:opacity-50 disabled:pointer-events-none"
|
|
disabled={isAnyDeleting}
|
|
onClick={async () => {
|
|
const ok = await confirm({
|
|
title: 'Delete service?',
|
|
message: `Permanently delete "${svc.name}"? This cannot be undone.`,
|
|
confirmText: 'Delete',
|
|
variant: 'danger',
|
|
});
|
|
if (ok) deleteApplication(svc.id);
|
|
}}
|
|
>
|
|
<DeleteButtonLabel loading={isDeleting(svc.id)} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|