Add application migration workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-19 00:31:29 +03:30
parent fda8384a5c
commit 41a276d16d
14 changed files with 1145 additions and 6 deletions
+225 -2
View File
@@ -5,8 +5,8 @@ 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, AppLifecycleStatus, BillingCycle } from '@/types';
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock } from 'lucide-react';
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 { useDebounce } from '@/hooks/useDebounce';
@@ -65,6 +65,8 @@ export default function AdminAppsPage() {
const confirm = useConfirm();
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 400);
const [migrateApp, setMigrateApp] = useState<Application | null>(null);
const [targetClusterId, setTargetClusterId] = useState('');
const { data: apps = [], isLoading } = useQuery<Application[]>({
queryKey: ['admin-applications', debouncedSearch],
@@ -83,6 +85,55 @@ export default function AdminAppsPage() {
onError: () => toast.error('Failed to delete application'),
});
const { data: clusters = [] } = useQuery<Cluster[]>({
queryKey: ['admin-clusters'],
queryFn: () => api.get('/clusters').then((r) => r.data),
});
const { data: migrations = [] } = useQuery<ApplicationMigrationJob[]>({
queryKey: ['application-migrations'],
queryFn: () => api.get('/application-migrations').then((r) => r.data),
refetchInterval: 5000,
});
const selectedMigration = migrateApp
? migrations.find((migration) => migration.applicationId === migrateApp.id)
: undefined;
const clusterById = new Map(clusters.map((cluster) => [cluster.id, cluster]));
const currentCluster = migrateApp?.clusterId ? clusterById.get(migrateApp.clusterId) : undefined;
const targetClusters = migrateApp
? clusters.filter((cluster) => cluster.id !== migrateApp.clusterId)
: [];
const { data: selectedMigrationEvents = [] } = useQuery<ApplicationMigrationEvent[]>({
queryKey: ['application-migration-events', selectedMigration?.id],
queryFn: () => api.get(`/application-migrations/${selectedMigration!.id}/events`).then((r) => r.data),
enabled: !!selectedMigration?.id,
refetchInterval: selectedMigration && ['queued', 'running', 'rolling_back'].includes(selectedMigration.status) ? 3000 : false,
});
const migrateMutation = useMutation({
mutationFn: ({ appId, clusterId }: { appId: string; clusterId: string }) =>
api.post(`/application-migrations/applications/${appId}`, {
targetClusterId: clusterId,
migrateStorage: true,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
toast.success('Migration job queued');
},
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to queue migration'),
});
const retryMigration = useMutation({
mutationFn: (id: string) => api.post(`/application-migrations/${id}/retry`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application-migrations'] });
toast.success('Migration retry queued');
},
onError: (err: any) => toast.error(err?.response?.data?.message || 'Failed to retry migration'),
});
// Compute status counts from apps
const statusCounts = apps.reduce(
(acc, app) => {
@@ -224,6 +275,7 @@ export default function AdminAppsPage() {
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Owner</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Service</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Cluster</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Plan / Expiry</th>
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
@@ -233,6 +285,8 @@ export default function AdminAppsPage() {
const latestStatus = app.deployments?.[0]?.status || 'pending';
const lifecycle = app.lifecycleStatus || 'active';
const expiry = formatExpiry(app.planExpiresAt);
const latestMigration = migrations.find((migration) => migration.applicationId === app.id);
const assignedCluster = app.clusterId ? clusterById.get(app.clusterId) : undefined;
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' : ''}`}>
<td className="px-6 py-4">
@@ -273,6 +327,23 @@ export default function AdminAppsPage() {
<p className="text-xs text-red-500 mt-1">Delete: {formatDeletionDate(app.scheduledDeletionAt)}</p>
)}
</td>
<td className="px-6 py-4">
{assignedCluster ? (
<div>
<p className="text-sm font-medium text-gray-900">{assignedCluster.name}</p>
<p className="text-xs text-gray-400">
{assignedCluster.region || 'N/A'} · {assignedCluster.status}/{assignedCluster.healthStatus || 'unknown'}
</p>
</div>
) : app.clusterId ? (
<div>
<p className="text-sm font-medium text-gray-700">Unknown cluster</p>
<p className="text-xs text-gray-400 font-mono">{app.clusterId.slice(0, 8)}</p>
</div>
) : (
<span className="text-xs text-gray-400">Not assigned</span>
)}
</td>
<td className="px-6 py-4">
{app.billingCycle && (
<span className="badge badge-purple text-xs">{cycleLabels[app.billingCycle] || app.billingCycle}</span>
@@ -290,6 +361,28 @@ export default function AdminAppsPage() {
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
View
</Link>
<button
onClick={() => {
setMigrateApp(app);
setTargetClusterId('');
}}
disabled={!app.clusterId}
className="btn-ghost text-xs px-3 py-1.5 inline-flex items-center gap-1"
title={!app.clusterId ? 'Application is not assigned to a cluster' : undefined}
>
<ArrowRightLeft className="w-3 h-3" /> Migrate
</button>
{latestMigration && (
<span className={`text-xs px-2 py-1 rounded-full ${
latestMigration.status === 'completed'
? 'bg-green-50 text-green-700'
: latestMigration.status === 'failed' || latestMigration.status === 'rolled_back'
? 'bg-red-50 text-red-700'
: 'bg-blue-50 text-blue-700'
}`}>
{latestMigration.status}
</span>
)}
<button
onClick={async () => {
const ok = await confirm({ title: 'Delete Application', message: `Are you sure you want to delete "${app.name}"?`, confirmText: 'Delete', variant: 'danger' });
@@ -343,12 +436,142 @@ export default function AdminAppsPage() {
<span className="flex items-center gap-1"><Database className="w-3 h-3" /> {app.databaseType}</span>
<span className="flex items-center gap-1"><Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Cluster:{' '}
<span className="font-medium text-gray-700">
{app.clusterId ? (clusterById.get(app.clusterId)?.name || app.clusterId.slice(0, 8)) : 'Not assigned'}
</span>
</div>
</Link>
);
})}
</div>
</>
)}
{migrateApp && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ArrowRightLeft className="w-5 h-5" /> Migrate Application
</h2>
<p className="text-sm text-gray-500 mt-1">
Move <span className="font-medium">{migrateApp.name}</span> to a healthy target cluster with logs, retry, and rollback.
</p>
</div>
<button onClick={() => setMigrateApp(null)} className="p-1 text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Target Cluster</label>
<div className="mb-3 rounded-xl bg-gray-50 border border-gray-100 p-3">
<p className="text-xs font-medium text-gray-500">Current cluster</p>
<p className="text-sm font-semibold text-gray-900 mt-1">
{currentCluster?.name || (migrateApp.clusterId ? `Unknown cluster (${migrateApp.clusterId.slice(0, 8)})` : 'Not assigned')}
</p>
{currentCluster && (
<p className="text-xs text-gray-500 mt-0.5">
{currentCluster.region || 'N/A'} · {currentCluster.status}/{currentCluster.healthStatus || 'unknown'}
</p>
)}
</div>
<select
className="input-field"
value={targetClusterId}
onChange={(e) => setTargetClusterId(e.target.value)}
>
<option value="">Select healthy cluster</option>
{targetClusters
.map((cluster) => (
<option
key={cluster.id}
value={cluster.id}
disabled={cluster.status !== 'active' || cluster.healthStatus !== 'healthy'}
>
{cluster.name} · {cluster.region || 'N/A'} · {cluster.status}/{cluster.healthStatus || 'unknown'}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">
The current cluster is excluded. Migration is blocked for unhealthy, inactive, or maintenance clusters.
</p>
{targetClusters.length === 0 && (
<p className="text-xs text-amber-600 mt-2">
No other cluster is available as a migration target.
</p>
)}
</div>
{selectedMigration && (
<div className="rounded-xl border border-gray-200 p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-gray-800">Latest migration</p>
<p className="text-xs text-gray-500">
{selectedMigration.currentStep || 'queued'} · attempts {selectedMigration.attempts}/{selectedMigration.maxAttempts}
</p>
</div>
<span className={`badge ${
selectedMigration.status === 'completed'
? 'badge-green'
: selectedMigration.status === 'failed' || selectedMigration.status === 'rolled_back'
? 'badge-red'
: 'badge-blue'
}`}>
{selectedMigration.status}
</span>
</div>
{selectedMigration.errorMessage && (
<p className="text-sm text-red-600">{selectedMigration.errorMessage}</p>
)}
<div className="max-h-40 overflow-auto rounded-lg bg-gray-50 p-3 space-y-2">
{selectedMigrationEvents.length === 0 ? (
<p className="text-xs text-gray-400">No events yet.</p>
) : selectedMigrationEvents.map((event) => (
<div key={event.id} className="text-xs">
<span className="font-mono text-gray-400">{new Date(event.createdAt).toLocaleTimeString()}</span>
<span className={`ml-2 font-medium ${
event.level === 'error' ? 'text-red-600' : event.level === 'warn' ? 'text-amber-600' : 'text-gray-700'
}`}>
{event.step}
</span>
<span className="ml-2 text-gray-600">{event.message}</span>
</div>
))}
</div>
{['failed', 'rolled_back'].includes(selectedMigration.status) && (
<button
type="button"
onClick={() => retryMigration.mutate(selectedMigration.id)}
disabled={retryMigration.isPending}
className="btn-secondary text-sm inline-flex items-center gap-1"
>
<RefreshCw className={`w-4 h-4 ${retryMigration.isPending ? 'animate-spin' : ''}`} />
Retry migration
</button>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button onClick={() => setMigrateApp(null)} className="btn-secondary">
Cancel
</button>
<button
onClick={() => migrateMutation.mutate({ appId: migrateApp.id, clusterId: targetClusterId })}
disabled={!targetClusterId || migrateMutation.isPending}
className="btn-primary disabled:opacity-50"
>
{migrateMutation.isPending ? 'Queueing...' : 'Start Migration'}
</button>
</div>
</div>
</div>
)}
</div>
);
}