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>
This commit is contained in:
@@ -801,6 +801,15 @@ export class BillingService {
|
||||
const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now);
|
||||
const remainingDays = Math.floor(remainingMs / 86400000);
|
||||
const remainingHours = Math.floor((remainingMs % 86400000) / 3600000);
|
||||
const remainingMinutes = Math.floor((remainingMs % 3600000) / 60000);
|
||||
const remainingLabel =
|
||||
remainingDays > 0
|
||||
? `${remainingDays}d ${remainingHours}h ${remainingMinutes}m`
|
||||
: remainingHours > 0
|
||||
? `${remainingHours}h ${remainingMinutes}m`
|
||||
: remainingMinutes > 0
|
||||
? `${remainingMinutes}m`
|
||||
: 'less than 1m';
|
||||
return {
|
||||
id: credit.id,
|
||||
sourceAppName: credit.sourceAppName,
|
||||
@@ -818,8 +827,10 @@ export class BillingService {
|
||||
billingCycle: credit.billingCycle,
|
||||
expiresAt: credit.expiresAt,
|
||||
remainingMs,
|
||||
remainingLabel:
|
||||
remainingDays > 0 ? `${remainingDays}d ${remainingHours}h` : `${remainingHours}h`,
|
||||
remainingDays,
|
||||
remainingHours,
|
||||
remainingMinutes,
|
||||
remainingLabel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import { toast } from 'react-toastify';
|
||||
import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, ApplicationMigrationJob, BillingCycle, Cluster } from '@/types';
|
||||
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
||||
import { DeletingCardOverlay, DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay';
|
||||
import { TruncatedText } from '@/components/truncated-text';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
@@ -96,13 +99,9 @@ export default function AdminAppsPage() {
|
||||
.then((r) => r.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-applications'] });
|
||||
toast.success('Application deleted');
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['admin-applications']],
|
||||
successMessage: 'Application deleted',
|
||||
});
|
||||
|
||||
const { data: clusters = [] } = useQuery<Cluster[]>({
|
||||
@@ -308,8 +307,19 @@ export default function AdminAppsPage() {
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
|
||||
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
|
||||
const rowDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<tr key={app.id} className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}>
|
||||
<tr
|
||||
key={app.id}
|
||||
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
{rowDeleting ? (
|
||||
<DeletingTableRowCell
|
||||
colSpan={8}
|
||||
message={deletingResourceMessage('application', app.name)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 min-w-[300px]">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-[300px] group">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
@@ -420,16 +430,19 @@ export default function AdminAppsPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
|
||||
if (ok) deleteMutation.mutate(app.id);
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors font-medium"
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors font-medium disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
Delete
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -445,11 +458,15 @@ export default function AdminAppsPage() {
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
|
||||
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
|
||||
const cardDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={`card space-y-3 ${lifecycle === 'suspended' ? 'border-amber-200/80' : lifecycle === 'pending_deletion' ? 'border-red-200/80' : ''}`}
|
||||
className={`relative card space-y-3 ${lifecycle === 'suspended' ? 'border-amber-200/80' : lifecycle === 'pending_deletion' ? 'border-red-200/80' : ''} ${cardDeleting ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
{cardDeleting && (
|
||||
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
|
||||
@@ -538,6 +555,7 @@ export default function AdminAppsPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
@@ -545,11 +563,11 @@ export default function AdminAppsPage() {
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(app.id);
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 font-medium"
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 font-medium disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
Delete
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ 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, ScrollText } from 'lucide-react';
|
||||
import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
|
||||
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
||||
import { DeletingModal } from '@/components/deleting-modal';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { isApplicationProduct, isManagedProduct } from '@/lib/product-type';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
@@ -388,8 +390,15 @@ export default function AppDetailPage() {
|
||||
onError: () => toast.error('Rollback failed'),
|
||||
});
|
||||
|
||||
const [deletingSnapshotId, setDeletingSnapshotId] = useState<string | null>(null);
|
||||
const deleteSnapshotMutation = useMutation({
|
||||
mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`),
|
||||
onMutate: (snapshotId) => {
|
||||
setDeletingSnapshotId(snapshotId);
|
||||
},
|
||||
onSettled: () => {
|
||||
setDeletingSnapshotId(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
|
||||
toast.success('Snapshot deleted');
|
||||
@@ -576,12 +585,10 @@ export default function AppDetailPage() {
|
||||
onError: () => toast.error('Failed to trigger redeploy'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${appId}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
const { deleteApplication, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'application'], ['applications']],
|
||||
onSuccess: (data) => {
|
||||
if (data?.resourceCredit) {
|
||||
toast.success('Application deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Application deleted');
|
||||
@@ -835,11 +842,18 @@ export default function AppDetailPage() {
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate();
|
||||
if (ok) deleteApplication(appId);
|
||||
};
|
||||
|
||||
const pageLocked = isAnyDeleting;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<>
|
||||
<DeletingModal open={pageLocked} resourceName={app.name} resourceKind="application" />
|
||||
<div
|
||||
className={`space-y-6 animate-fade-in ${pageLocked ? 'pointer-events-none select-none opacity-50' : ''}`}
|
||||
aria-hidden={pageLocked}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
@@ -905,8 +919,12 @@ export default function AppDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleteMutation.isPending} className="btn-danger text-sm disabled:opacity-50">
|
||||
{deleteMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : 'Delete'}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={pageLocked}
|
||||
className="btn-danger text-sm disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2635,11 +2653,15 @@ export default function AppDetailPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSnapshot(snap)}
|
||||
disabled={deleteSnapshotMutation.isPending}
|
||||
disabled={deletingSnapshotId !== null}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Delete snapshot"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2663,5 +2685,6 @@ export default function AppDetailPage() {
|
||||
isStopped={isStopped}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } 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 { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
||||
import { DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay';
|
||||
import { filterApplications } from '@/lib/product-type';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
@@ -49,7 +51,6 @@ function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
}
|
||||
|
||||
export default function AppsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: appsRaw = [], isLoading } = useQuery<Application[]>({
|
||||
@@ -58,18 +59,10 @@ export default function AppsPage() {
|
||||
});
|
||||
const apps = filterApplications(appsRaw);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
toast.success('Application deleted. Your prepaid resources are shown on the dashboard.');
|
||||
} else {
|
||||
toast.success('Application deleted');
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to delete application'),
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'application'], ['applications']],
|
||||
successMessage: 'Application deleted',
|
||||
successWithCreditMessage: 'Application deleted. Your prepaid resources are shown on the dashboard.',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -135,11 +128,19 @@ export default function AppsPage() {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const rowDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<tr
|
||||
key={app.id}
|
||||
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''}`}
|
||||
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
{rowDeleting ? (
|
||||
<DeletingTableRowCell
|
||||
colSpan={6}
|
||||
message={deletingResourceMessage('application', app.name)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 min-w-[300px]">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group min-w-[300px]">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
|
||||
@@ -175,6 +176,7 @@ export default function AppsPage() {
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
@@ -182,14 +184,16 @@ export default function AppsPage() {
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(app.id);
|
||||
if (ok) deleteApplication(app.id);
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
Delete
|
||||
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2674,7 +2674,11 @@ export default function DeployPage() {
|
||||
<p className="font-medium">Prepaid credit applied</p>
|
||||
<p className="text-indigo-700 mt-0.5">
|
||||
Resources from "{costData.creditApplied.sourceAppName || 'deleted app'}" are covered
|
||||
until {costData.creditApplied.remainingLabel} remaining.
|
||||
until {costData.creditApplied.remainingLabel} remaining
|
||||
{costData.creditApplied.expiresAt
|
||||
? ` (expires ${new Date(costData.creditApplied.expiresAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })})`
|
||||
: ''}
|
||||
.
|
||||
{payAmount > 0
|
||||
? ` You only pay for new add-ons below (prorated to ${costData.prorateRemainingDays ?? '?'}/${costData.proratePeriodDays ?? '?'} days left on your credit).`
|
||||
: ' No charge for this deploy.'}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/lib/store';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Circle, Hexagon, Wallet, Clock, Database, Plus } from 'lucide-react';
|
||||
import type { ResourceCredit } from '@/types';
|
||||
import { formatExpiresAtLocal } from '@/lib/format-utils';
|
||||
import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
||||
import { filterApplications, filterManagedServices } from '@/lib/product-type';
|
||||
|
||||
@@ -112,6 +113,9 @@ export default function DashboardPage() {
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{credit.remainingLabel} remaining
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Expires {formatExpiresAtLocal(credit.expiresAt)}
|
||||
</p>
|
||||
<ul className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-gray-600">
|
||||
<li>CPU: {credit.cpuLimit}</li>
|
||||
<li>RAM: {credit.memoryLimit}</li>
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { DeletingModal } from '@/components/deleting-modal';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
|
||||
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
||||
import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel';
|
||||
@@ -155,12 +157,11 @@ export default function ManagedServiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${serviceId}`),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['applications', 'managed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
if (res.data?.resourceCredit) {
|
||||
const { deleteApplication, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'managed'], ['application', serviceId]],
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
if (data?.resourceCredit) {
|
||||
toast.success('Service deleted. Prepaid resources are on your dashboard.');
|
||||
} else {
|
||||
toast.success('Service deleted');
|
||||
@@ -228,8 +229,15 @@ export default function ManagedServiceDetailPage() {
|
||||
? `${optionalRes.memoryRequest} / ${optionalRes.memoryLimit}`
|
||||
: '—';
|
||||
|
||||
const pageLocked = isAnyDeleting;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<>
|
||||
<DeletingModal open={pageLocked} resourceName={app.name} resourceKind="service" />
|
||||
<div
|
||||
className={`space-y-6 ${pageLocked ? 'pointer-events-none select-none opacity-50' : ''}`}
|
||||
aria-hidden={pageLocked}
|
||||
>
|
||||
<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">
|
||||
@@ -294,7 +302,8 @@ export default function ManagedServiceDetailPage() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger text-sm"
|
||||
className="btn-danger text-sm disabled:opacity-50"
|
||||
disabled={pageLocked}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete service?',
|
||||
@@ -302,7 +311,7 @@ export default function ManagedServiceDetailPage() {
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate();
|
||||
if (ok) deleteApplication(serviceId);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
@@ -697,5 +706,6 @@ export default function ManagedServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } 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 { 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',
|
||||
@@ -50,7 +52,6 @@ function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: servicesRaw = [], isLoading } = useQuery<Application[]>({
|
||||
@@ -59,18 +60,10 @@ export default function ServicesPage() {
|
||||
});
|
||||
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'),
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'managed']],
|
||||
successMessage: 'Service deleted',
|
||||
successWithCreditMessage: 'Service deleted. Prepaid resources are on your dashboard.',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -126,13 +119,17 @@ export default function ServicesPage() {
|
||||
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={`card-hover flex flex-col sm:flex-row sm:items-center gap-4 ${
|
||||
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' : ''}`}
|
||||
} ${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" />
|
||||
@@ -161,7 +158,8 @@ export default function ServicesPage() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm text-red-600"
|
||||
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?',
|
||||
@@ -169,10 +167,10 @@ export default function ServicesPage() {
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(svc.id);
|
||||
if (ok) deleteApplication(svc.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<DeleteButtonLabel loading={isDeleting(svc.id)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,8 +109,15 @@ export function DatabaseSnapshotsPanel({
|
||||
},
|
||||
});
|
||||
|
||||
const [deletingSnapshotId, setDeletingSnapshotId] = useState<string | null>(null);
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/snapshots/${id}`),
|
||||
onMutate: (id) => {
|
||||
setDeletingSnapshotId(id);
|
||||
},
|
||||
onSettled: () => {
|
||||
setDeletingSnapshotId(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||
toast.success('Backup deleted');
|
||||
@@ -281,11 +288,15 @@ export function DatabaseSnapshotsPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={deletingSnapshotId !== null}
|
||||
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" />
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -293,11 +304,15 @@ export function DatabaseSnapshotsPanel({
|
||||
<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"
|
||||
disabled={deletingSnapshotId !== null}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0 disabled:opacity-50"
|
||||
title="Remove failed backup"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
export function DeleteButtonLabel({ loading, label = 'Delete' }: { loading?: boolean; label?: string }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> Deleting…
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{label}</>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
export function DeletingModal({
|
||||
open,
|
||||
resourceName,
|
||||
resourceKind = 'application',
|
||||
}: {
|
||||
open: boolean;
|
||||
resourceName: string;
|
||||
resourceKind?: 'application' | 'service';
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
const detail =
|
||||
resourceKind === 'service'
|
||||
? 'Removing this service and its data from the cluster. This may take a minute.'
|
||||
: 'Removing this application, deployments, and data from the cluster. This may take a minute.';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/45 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="deleting-modal-title"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-8 text-center space-y-3">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<Clock className="w-7 h-7 text-red-600 animate-spin" />
|
||||
</div>
|
||||
<h2 id="deleting-modal-title" className="text-lg font-semibold text-gray-900">
|
||||
Deleting…
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{resourceName}</span> is being permanently removed.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{detail}</p>
|
||||
<p className="text-xs text-amber-700 font-medium pt-1">Please wait — do not close this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
export function deletingResourceMessage(
|
||||
kind: 'application' | 'service',
|
||||
name?: string,
|
||||
): string {
|
||||
if (kind === 'service') {
|
||||
return name ? `Deleting “${name}”…` : 'Deleting service…';
|
||||
}
|
||||
return name ? `Deleting “${name}”…` : 'Deleting application…';
|
||||
}
|
||||
|
||||
/** Single table cell spanning the full row — content centered in the row. */
|
||||
export function DeletingTableRowCell({
|
||||
colSpan,
|
||||
message = 'Deleting…',
|
||||
}: {
|
||||
colSpan: number;
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<td colSpan={colSpan} className="px-6 py-4 bg-white/95" aria-live="polite" aria-busy="true">
|
||||
<div className="flex min-h-[52px] w-full items-center justify-center gap-2 text-sm font-semibold text-gray-800">
|
||||
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-card overlay while delete is in progress. Parent must be `relative`. */
|
||||
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/92 backdrop-blur-[2px] text-sm font-semibold text-gray-800"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,3 +18,25 @@ export function parseMemoryToMi(mem: string): number {
|
||||
if (mem.endsWith('Ki')) return parseFloat(mem) / 1024;
|
||||
return parseFloat(mem);
|
||||
}
|
||||
|
||||
/** Human-readable time left (days, hours, minutes). */
|
||||
export function formatRemainingDurationMs(remainingMs: number): string {
|
||||
const ms = Math.max(0, remainingMs);
|
||||
const days = Math.floor(ms / 86400000);
|
||||
const hours = Math.floor((ms % 86400000) / 3600000);
|
||||
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return 'less than 1m';
|
||||
}
|
||||
|
||||
/** Expiry timestamp in the user's locale and timezone. */
|
||||
export function formatExpiresAtLocal(expiresAt: string | Date): string {
|
||||
const date = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
type DeleteResponse = { resourceCredit?: unknown };
|
||||
|
||||
export function useApplicationDelete(options?: {
|
||||
invalidateKeys?: unknown[][];
|
||||
onSuccess?: (res: DeleteResponse, id: string) => void;
|
||||
onError?: () => void;
|
||||
successMessage?: string;
|
||||
successWithCreditMessage?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/applications/${id}`).then((r) => r.data as DeleteResponse),
|
||||
onMutate: (id) => {
|
||||
setDeletingId(id);
|
||||
},
|
||||
onSettled: () => {
|
||||
setDeletingId(null);
|
||||
},
|
||||
onSuccess: (data, id) => {
|
||||
for (const key of options?.invalidateKeys ?? [['applications']]) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['resource-credits'] });
|
||||
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(data, id);
|
||||
} else if (data?.resourceCredit && options?.successWithCreditMessage) {
|
||||
toast.success(options.successWithCreditMessage);
|
||||
} else {
|
||||
toast.success(options?.successMessage ?? 'Deleted successfully');
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
if (options?.onError) options.onError();
|
||||
else toast.error('Failed to delete');
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteApplication: mutation.mutate,
|
||||
deletingId,
|
||||
isDeleting: (id: string) => mutation.isPending && deletingId === id,
|
||||
isAnyDeleting: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -187,6 +187,9 @@ export interface ResourceCredit {
|
||||
billingCycle?: string;
|
||||
expiresAt: string;
|
||||
remainingMs: number;
|
||||
remainingDays?: number;
|
||||
remainingHours?: number;
|
||||
remainingMinutes?: number;
|
||||
remainingLabel: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user