Files
cloud-host/frontend/src/app/dashboard/services/page.tsx
T
keyhan abbe821d91 Improve delete UX and prepaid credit time display.
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>
2026-05-24 00:33:16 +03:30

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