feat: add snapshot/rollback system with download capability

- Add AppSnapshot entity with type (pre_deploy/manual), status tracking, and file paths
- Add SnapshotsService with create, capture, rollback, prune (max 10), and download logic
- Add SnapshotsController with REST endpoints for CRUD, rollback, and file downloads
- Add K8s methods: exportDatabaseDump, archiveWpContent, restoreWpContent
- Auto-create pre-deploy snapshots before each deployment for rollback safety
- Support downloading current live state (source, wp-content, database) without snapshots
- Add snapshot management UI in app detail page with create, rollback, download, delete
- Wire circular dependencies with forwardRef between Deployments and Snapshots modules
This commit is contained in:
keyhan
2026-04-08 01:55:00 +03:30
parent ac5278d73b
commit d3a5240528
11 changed files with 1286 additions and 3 deletions
+291 -2
View File
@@ -4,9 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic } from '@/types';
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot } from '@/types';
import { useState, useRef, useCallback, useEffect } from 'react';
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 } from 'lucide-react';
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 } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
const statusColors: Record<string, string> = {
@@ -70,6 +70,7 @@ export default function AppDetailPage() {
});
const [dbStorageSize, setDbStorageSize] = useState('1');
const [dbStorageLoading, setDbStorageLoading] = useState(false);
const [showSnapshots, setShowSnapshots] = useState(false);
const { data: app, isLoading } = useQuery<Application>({
queryKey: ['application', appId],
@@ -143,6 +144,108 @@ export default function AppDetailPage() {
},
});
// ─── Snapshots ──────────────────────────────────────
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
queryKey: ['snapshots', appId],
queryFn: () => api.get(`/snapshots/applications/${appId}`).then((r) => r.data),
enabled: showSnapshots,
refetchInterval: showSnapshots ? 10000 : false,
});
const createSnapshotMutation = useMutation({
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
toast.success('Snapshot creation started');
},
onError: () => toast.error('Failed to create snapshot'),
});
const rollbackMutation = useMutation({
mutationFn: (snapshotId: string) => api.post(`/snapshots/${snapshotId}/rollback`),
onSuccess: (res) => {
const details = res.data.details || [];
toast.success('Rollback completed:\n' + details.join('\n'));
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
},
onError: () => toast.error('Rollback failed'),
});
const deleteSnapshotMutation = useMutation({
mutationFn: (snapshotId: string) => api.delete(`/snapshots/${snapshotId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['snapshots', appId] });
toast.success('Snapshot deleted');
},
onError: () => toast.error('Failed to delete snapshot'),
});
const handleRollback = async (snap: AppSnapshot) => {
const ok = await confirm({
title: `Rollback to "${snap.label}"?`,
message: 'This will restore:\n• Source code (if available)\n• wp-content files (WordPress)\n• Database dump\n\nThe current state will be overwritten.',
confirmText: 'Rollback',
variant: 'warning',
});
if (ok) rollbackMutation.mutate(snap.id);
};
const handleDeleteSnapshot = async (snap: AppSnapshot) => {
const ok = await confirm({
title: 'Delete snapshot?',
message: `Delete "${snap.label}"? The archived files will be permanently removed.`,
confirmText: 'Delete',
variant: 'danger',
});
if (ok) deleteSnapshotMutation.mutate(snap.id);
};
const downloadSnapshotArtifact = (snapshotId: string, artifact: 'source' | 'wp-content' | 'database') => {
const url = `${api.defaults.baseURL}/snapshots/${snapshotId}/download/${artifact}`;
const token = localStorage.getItem('accessToken');
const a = document.createElement('a');
a.href = url;
// Use fetch for auth download
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => r.blob())
.then((blob) => {
const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql';
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${artifact}-${snapshotId.slice(0, 8)}${ext}`;
link.click();
URL.revokeObjectURL(link.href);
})
.catch(() => toast.error(`Failed to download ${artifact}`));
};
const downloadCurrentArtifact = (artifact: 'source' | 'wp-content' | 'database') => {
const url = `${api.defaults.baseURL}/snapshots/applications/${appId}/current/${artifact}`;
const token = localStorage.getItem('accessToken');
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((r) => {
if (!r.ok) throw new Error('Not found');
return r.blob();
})
.then((blob) => {
const ext = artifact === 'source' ? '.zip' : artifact === 'wp-content' ? '.tar.gz' : '.sql';
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `current-${artifact}${ext}`;
link.click();
URL.revokeObjectURL(link.href);
})
.catch(() => toast.error(`Failed to download current ${artifact}`));
};
const formatBytes = (bytes?: number) => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// Sync form when resource data loads
useEffect(() => {
if (resourceUsage?.configured) {
@@ -1059,6 +1162,192 @@ export default function AppDetailPage() {
)}
</div>
{/* Snapshots & Rollback */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><History className="w-5 h-5" /> Snapshots & Rollback</h2>
<div className="flex items-center gap-2">
<button
onClick={() => createSnapshotMutation.mutate()}
disabled={createSnapshotMutation.isPending}
className="btn-secondary text-sm disabled:opacity-50"
>
{createSnapshotMutation.isPending
? <><Clock className="w-3 h-3 inline animate-spin" /> Creating...</>
: <><Camera className="w-3 h-3 inline" /> New Snapshot</>}
</button>
<button
onClick={() => setShowSnapshots(!showSnapshots)}
className="btn-secondary text-sm"
>
{showSnapshots ? <><ChevronDown className="w-4 h-4 inline" /> Hide</> : <><History className="w-4 h-4 inline" /> Show</>}
</button>
</div>
</div>
{showSnapshots && (
<div className="space-y-4">
{/* Download current live state */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-blue-800 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" /> Download Current State
</h3>
<p className="text-xs text-blue-600 mb-3">Download a copy of the current live files without creating a snapshot.</p>
<div className="flex flex-wrap gap-2">
{app.codePath && (
<button
onClick={() => downloadCurrentArtifact('source')}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<Archive className="w-3 h-3" /> Source Code
</button>
)}
{app.runtime === 'wordpress' && (
<button
onClick={() => downloadCurrentArtifact('wp-content')}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<Archive className="w-3 h-3" /> wp-content
</button>
)}
{app.databaseType !== 'none' && (
<button
onClick={() => downloadCurrentArtifact('database')}
className="text-xs px-3 py-1.5 rounded-lg bg-white border border-blue-200 text-blue-700 hover:bg-blue-100 transition-colors flex items-center gap-1"
>
<Database className="w-3 h-3" /> Database Dump
</button>
)}
</div>
</div>
{/* Snapshot list */}
{snapshotsLoading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading snapshots...</div>
) : snapshots.length === 0 ? (
<div className="text-center py-8">
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
<p className="text-gray-500 text-sm">No snapshots yet</p>
<p className="text-gray-400 text-xs mt-1">Snapshots are created automatically before each deploy, or you can create one manually.</p>
</div>
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{snapshots.map((snap) => (
<div key={snap.id} className={`border rounded-xl p-4 transition-all ${
snap.status === 'completed' ? 'border-gray-200 bg-white' :
snap.status === 'in_progress' ? 'border-blue-200 bg-blue-50' :
'border-red-200 bg-red-50'
}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Untitled'}</p>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
}`}>
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
snap.status === 'completed' ? 'bg-green-100 text-green-700' :
snap.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
'bg-red-100 text-red-700'
}`}>
{snap.status === 'in_progress' ? 'Creating...' : snap.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">
{new Date(snap.createdAt).toLocaleString()}
{snap.imageTag && <span className="ml-2 font-mono text-gray-400">image: {snap.imageTag.split(':').pop()?.slice(0, 12)}</span>}
</p>
{/* Artifact sizes */}
{snap.status === 'completed' && (
<div className="flex flex-wrap gap-3 mt-2">
{snap.appArchivePath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Package className="w-3 h-3" /> Source: {formatBytes(snap.appArchiveSize)}
</span>
)}
{snap.wpContentArchivePath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Archive className="w-3 h-3" /> wp-content: {formatBytes(snap.wpContentSize)}
</span>
)}
{snap.dbDumpPath && (
<span className="text-xs text-gray-500 flex items-center gap-1">
<Database className="w-3 h-3" /> DB: {formatBytes(snap.dbDumpSize)}
</span>
)}
</div>
)}
{snap.errorMessage && (
<p className="text-xs text-red-500 mt-1 truncate" title={snap.errorMessage}>
<XCircle className="w-3 h-3 inline" /> {snap.errorMessage}
</p>
)}
</div>
{/* Actions */}
{snap.status === 'completed' && (
<div className="flex items-center gap-1 shrink-0">
{/* Download dropdown-style buttons */}
{snap.appArchivePath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'source')}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Download source code"
>
<Download className="w-4 h-4" />
</button>
)}
{snap.wpContentArchivePath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'wp-content')}
className="p-1.5 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
title="Download wp-content"
>
<Archive className="w-4 h-4" />
</button>
)}
{snap.dbDumpPath && (
<button
onClick={() => downloadSnapshotArtifact(snap.id, 'database')}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Download database dump"
>
<Database className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleRollback(snap)}
disabled={rollbackMutation.isPending}
className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors disabled:opacity-50"
title="Rollback to this snapshot"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteSnapshot(snap)}
disabled={deleteSnapshotMutation.isPending}
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" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 text-center">Maximum 10 snapshots are kept. Older snapshots are automatically deleted.</p>
</div>
)}
</div>
{/* Logs — Pod & Build */}
<div className="card">
<div className="flex items-center justify-between mb-4">