feat: use Kubernetes revision-based rollback for instant app rollback
- Add revisionHistoryLimit: 10 and change-cause annotation to K8s deployments - Add getDeploymentRevisions() to list ReplicaSet revision history - Add rollbackDeploymentRevision() using K8s API (instant, no rebuild) - Refactor snapshot rollback: use K8s revision for app, keep file-based DB/wp-content restore - Add GET /snapshots/applications/:appId/revisions endpoint - Add POST /snapshots/applications/:appId/revisions/:rev/rollback endpoint - Add K8sRevision and K8sRevisionData types to frontend - Redesign UI with two tabs: K8s Revisions (instant) + File Snapshots (full backup) - K8s Revisions tab shows deployment history with one-click instant rollback - File Snapshots tab retains download, DB restore, and wp-content restore
This commit is contained in:
@@ -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, AppSnapshot } from '@/types';
|
||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } 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, History, Download, RotateCcw, Camera, Trash2, Archive } 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, Zap } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
@@ -71,6 +71,7 @@ export default function AppDetailPage() {
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
@@ -148,10 +149,39 @@ export default function AppDetailPage() {
|
||||
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,
|
||||
enabled: showSnapshots && snapshotTab === 'snapshots',
|
||||
refetchInterval: showSnapshots && snapshotTab === 'snapshots' ? 10000 : false,
|
||||
});
|
||||
|
||||
// ─── K8s Revisions (instant rollback) ──────────────
|
||||
const { data: revisionData, isLoading: revisionsLoading } = useQuery<K8sRevisionData>({
|
||||
queryKey: ['revisions', appId],
|
||||
queryFn: () => api.get(`/snapshots/applications/${appId}/revisions`).then((r) => r.data),
|
||||
enabled: showSnapshots && snapshotTab === 'revisions',
|
||||
refetchInterval: showSnapshots && snapshotTab === 'revisions' ? 10000 : false,
|
||||
});
|
||||
|
||||
const revisionRollbackMutation = useMutation({
|
||||
mutationFn: (revision: number) => api.post(`/snapshots/applications/${appId}/revisions/${revision}/rollback`),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.data.message || 'Rollback completed');
|
||||
queryClient.invalidateQueries({ queryKey: ['revisions', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'Rollback failed'),
|
||||
});
|
||||
|
||||
const handleRevisionRollback = async (rev: K8sRevision) => {
|
||||
const ok = await confirm({
|
||||
title: `Rollback to Revision ${rev.revision}?`,
|
||||
message: `This will instantly switch to:\n\nImage: ${rev.image}\n${rev.changeCause ? `Reason: ${rev.changeCause}` : ''}\n\nNo rebuild needed — takes effect in seconds.`,
|
||||
confirmText: 'Rollback',
|
||||
variant: 'warning',
|
||||
});
|
||||
if (ok) revisionRollbackMutation.mutate(rev.revision);
|
||||
};
|
||||
|
||||
const createSnapshotMutation = useMutation({
|
||||
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
|
||||
onSuccess: () => {
|
||||
@@ -184,7 +214,7 @@ export default function AppDetailPage() {
|
||||
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.',
|
||||
message: 'This will restore:\n• App deployment via K8s revision (instant)\n• wp-content files (WordPress)\n• Database dump\n\nThe current state will be overwritten.',
|
||||
confirmText: 'Rollback',
|
||||
variant: 'warning',
|
||||
});
|
||||
@@ -1165,7 +1195,7 @@ export default function AppDetailPage() {
|
||||
{/* 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>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><History className="w-5 h-5" /> Rollback & Snapshots</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => createSnapshotMutation.mutate()}
|
||||
@@ -1187,163 +1217,254 @@ export default function AppDetailPage() {
|
||||
|
||||
{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>
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl">
|
||||
<button
|
||||
onClick={() => setSnapshotTab('revisions')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
snapshotTab === 'revisions' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" /> K8s Revisions
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">Instant</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSnapshotTab('snapshots')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
snapshotTab === 'snapshots' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Camera className="w-4 h-4" /> File Snapshots
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">Full</span>
|
||||
</button>
|
||||
</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>
|
||||
{/* ─── K8s Revisions Tab ─── */}
|
||||
{snapshotTab === 'revisions' && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3">
|
||||
<p className="text-xs text-amber-700">
|
||||
<Zap className="w-3 h-3 inline" /> <strong>Instant rollback</strong> using Kubernetes deployment revisions. Switches the active container image in seconds — no rebuild needed. Up to 10 revisions are kept.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{revisionsLoading ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">Loading revisions...</div>
|
||||
) : !revisionData?.revisions?.length ? (
|
||||
<div className="text-center py-8">
|
||||
<History className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-500 text-sm">No revisions available</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Revisions appear after the first deployment.</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{revisionData.revisions.map((rev) => (
|
||||
<div key={rev.revision} className={`border rounded-xl p-4 transition-all ${
|
||||
rev.isCurrent ? 'border-green-300 bg-green-50 ring-1 ring-green-200' : 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-gray-900">Revision {rev.revision}</span>
|
||||
{rev.isCurrent && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono truncate" title={rev.image}>{rev.image}</p>
|
||||
{rev.changeCause && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate" title={rev.changeCause}>{rev.changeCause}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-0.5">{new Date(rev.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
{!rev.isCurrent && (
|
||||
<button
|
||||
onClick={() => handleRevisionRollback(rev)}
|
||||
disabled={revisionRollbackMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-lg hover:bg-amber-100 transition-colors disabled:opacity-50"
|
||||
title="Instant rollback to this revision"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Rollback
|
||||
</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>
|
||||
{/* ─── File Snapshots Tab ─── */}
|
||||
{snapshotTab === 'snapshots' && (
|
||||
<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>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Camera className="w-3 h-3 inline" /> <strong>Full snapshots</strong> include source code, database dump, and wp-content. Use these to restore data or download backups. Auto-created before each deploy.
|
||||
</p>
|
||||
</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">
|
||||
{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 (uses K8s revision + restores DB/wp-content)"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -311,3 +311,17 @@ export interface AppSnapshot {
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface K8sRevision {
|
||||
revision: number;
|
||||
image: string;
|
||||
changeCause: string;
|
||||
createdAt: string;
|
||||
replicas: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface K8sRevisionData {
|
||||
revisions: K8sRevision[];
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user