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:
@@ -221,8 +221,12 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
name: ctx.appName,
|
name: ctx.appName,
|
||||||
namespace: ctx.namespace,
|
namespace: ctx.namespace,
|
||||||
labels: { app: ctx.appName, runtime: ctx.runtime },
|
labels: { app: ctx.appName, runtime: ctx.runtime },
|
||||||
|
annotations: {
|
||||||
|
'kubernetes.io/change-cause': `Deploy ${ctx.image} at ${new Date().toISOString()}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
|
revisionHistoryLimit: 10,
|
||||||
replicas: ctx.replicas,
|
replicas: ctx.replicas,
|
||||||
selector: { matchLabels: { app: ctx.appName } },
|
selector: { matchLabels: { app: ctx.appName } },
|
||||||
template: {
|
template: {
|
||||||
@@ -1422,6 +1426,146 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return { success: succeeded && !failed, logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out') };
|
return { success: succeeded && !failed, logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── K8s Revision-based Rollback ─────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of deployment revisions (ReplicaSets) for an application.
|
||||||
|
* Returns up to 10 revisions sorted newest-first with image, change-cause, and creation time.
|
||||||
|
*/
|
||||||
|
async getDeploymentRevisions(app: Application): Promise<{
|
||||||
|
revisions: Array<{
|
||||||
|
revision: number;
|
||||||
|
image: string;
|
||||||
|
changeCause: string;
|
||||||
|
createdAt: string;
|
||||||
|
replicas: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}>;
|
||||||
|
currentRevision: number;
|
||||||
|
}> {
|
||||||
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
// Get the deployment to find the current revision
|
||||||
|
let currentRevision = 0;
|
||||||
|
try {
|
||||||
|
const dep = await appsApi.readNamespacedDeployment(app.name, namespace);
|
||||||
|
currentRevision = parseInt(dep.body.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Could not read deployment for ${app.name}: ${e.message}`);
|
||||||
|
return { revisions: [], currentRevision: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// List ReplicaSets owned by this deployment
|
||||||
|
const rsList = await appsApi.listNamespacedReplicaSet(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`app=${app.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const revisions = rsList.body.items
|
||||||
|
.filter((rs) => {
|
||||||
|
// Must be owned by our deployment
|
||||||
|
const owners = rs.metadata?.ownerReferences || [];
|
||||||
|
return owners.some((o) => o.kind === 'Deployment' && o.name === app.name);
|
||||||
|
})
|
||||||
|
.map((rs) => {
|
||||||
|
const rev = parseInt(rs.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||||
|
const image = rs.spec?.template?.spec?.containers?.[0]?.image || 'unknown';
|
||||||
|
const changeCause = rs.metadata?.annotations?.['kubernetes.io/change-cause'] || '';
|
||||||
|
const createdAt = rs.metadata?.creationTimestamp?.toISOString() || '';
|
||||||
|
const replicas = rs.status?.replicas || 0;
|
||||||
|
return {
|
||||||
|
revision: rev,
|
||||||
|
image,
|
||||||
|
changeCause,
|
||||||
|
createdAt,
|
||||||
|
replicas,
|
||||||
|
isCurrent: rev === currentRevision,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.revision - a.revision)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return { revisions, currentRevision };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a K8s Deployment to a specific revision using the K8s API.
|
||||||
|
* This is equivalent to `kubectl rollout undo deployment/<name> --to-revision=<rev>`.
|
||||||
|
* It's instant — no rebuild needed, K8s just switches the active ReplicaSet.
|
||||||
|
*/
|
||||||
|
async rollbackDeploymentRevision(
|
||||||
|
app: Application,
|
||||||
|
targetRevision: number,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read the target ReplicaSet's pod template
|
||||||
|
const rsList = await appsApi.listNamespacedReplicaSet(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
`app=${app.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRs = rsList.body.items.find((rs) => {
|
||||||
|
const rev = parseInt(rs.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||||
|
const owners = rs.metadata?.ownerReferences || [];
|
||||||
|
return rev === targetRevision && owners.some((o) => o.kind === 'Deployment' && o.name === app.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetRs) {
|
||||||
|
return { success: false, message: `Revision ${targetRevision} not found` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the pod template from the target ReplicaSet
|
||||||
|
const targetTemplate = targetRs.spec?.template;
|
||||||
|
if (!targetTemplate) {
|
||||||
|
return { success: false, message: 'Could not read pod template from target revision' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch the deployment with the target revision's pod template
|
||||||
|
// This triggers a new rollout that uses the same image/config as the target revision
|
||||||
|
const patch = {
|
||||||
|
metadata: {
|
||||||
|
annotations: {
|
||||||
|
'kubernetes.io/change-cause': `Rollback to revision ${targetRevision} at ${new Date().toISOString()}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
template: targetTemplate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await appsApi.patchNamespacedDeployment(
|
||||||
|
app.name,
|
||||||
|
namespace,
|
||||||
|
patch,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const image = targetTemplate.spec?.containers?.[0]?.image || 'unknown';
|
||||||
|
this.logger.log(`Rolled back ${app.name} to revision ${targetRevision} (image: ${image})`);
|
||||||
|
return { success: true, message: `Rolled back to revision ${targetRevision} (image: ${image})` };
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`Failed to rollback ${app.name}: ${e.body?.message || e.message}`);
|
||||||
|
return { success: false, message: e.body?.message || e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private generatePassword(length = 24): string {
|
private generatePassword(length = 24): string {
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
||||||
let password = '';
|
let password = '';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Body,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -27,6 +28,25 @@ export class SnapshotsController {
|
|||||||
|
|
||||||
constructor(private readonly snapshotsService: SnapshotsService) {}
|
constructor(private readonly snapshotsService: SnapshotsService) {}
|
||||||
|
|
||||||
|
// ─── K8s Revision-based Rollback (instant) ─────────
|
||||||
|
|
||||||
|
@Get('applications/:appId/revisions')
|
||||||
|
@ApiOperation({ summary: 'Get K8s deployment revision history (up to 10)' })
|
||||||
|
async getRevisions(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
return this.snapshotsService.getRevisions(appId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('applications/:appId/revisions/:revision/rollback')
|
||||||
|
@ApiOperation({ summary: 'Rollback app deployment to a K8s revision (instant, no rebuild)' })
|
||||||
|
async rollbackToRevision(
|
||||||
|
@Param('appId') appId: string,
|
||||||
|
@Param('revision') revision: string,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
const result = await this.snapshotsService.rollbackToRevision(appId, req.user.id, parseInt(revision, 10));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Snapshot CRUD ──────────────────────────────────
|
// ─── Snapshot CRUD ──────────────────────────────────
|
||||||
|
|
||||||
@Post('applications/:appId')
|
@Post('applications/:appId')
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ export class SnapshotsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rollback an application to a specific snapshot.
|
* Rollback an application to a specific snapshot.
|
||||||
* Restores: source code, wp-content (WordPress), and database.
|
* Uses K8s revision rollback for the app deployment (instant, no rebuild).
|
||||||
|
* File-based restore for DB dump and wp-content.
|
||||||
*/
|
*/
|
||||||
async rollbackToSnapshot(snapshotId: string, userId: string): Promise<{ success: boolean; details: string[] }> {
|
async rollbackToSnapshot(snapshotId: string, userId: string): Promise<{ success: boolean; details: string[] }> {
|
||||||
const snapshot = await this.findOne(snapshotId, userId);
|
const snapshot = await this.findOne(snapshotId, userId);
|
||||||
@@ -299,20 +300,45 @@ export class SnapshotsService {
|
|||||||
const app = await this.applicationsService.findOne(snapshot.applicationId, userId);
|
const app = await this.applicationsService.findOne(snapshot.applicationId, userId);
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
|
|
||||||
// 1. Restore source code
|
// 1. Rollback K8s Deployment via revision (instant — no rebuild)
|
||||||
|
// Find the revision that matches the snapshot's imageTag
|
||||||
|
if (snapshot.imageTag) {
|
||||||
|
try {
|
||||||
|
const { revisions } = await this.kubernetesService.getDeploymentRevisions(app);
|
||||||
|
const targetRevision = revisions.find((r) => r.image === snapshot.imageTag);
|
||||||
|
|
||||||
|
if (targetRevision && !targetRevision.isCurrent) {
|
||||||
|
const result = await this.kubernetesService.rollbackDeploymentRevision(app, targetRevision.revision);
|
||||||
|
if (result.success) {
|
||||||
|
details.push(`✅ App rolled back to K8s revision ${targetRevision.revision} (instant)`);
|
||||||
|
this.logger.log(`Rollback ${snapshotId}: K8s revision rollback to ${targetRevision.revision}`);
|
||||||
|
} else {
|
||||||
|
details.push(`⚠️ K8s revision rollback failed: ${result.message}`);
|
||||||
|
}
|
||||||
|
} else if (targetRevision?.isCurrent) {
|
||||||
|
details.push('ℹ️ App is already running the snapshot\'s image — no deployment change needed');
|
||||||
|
} else {
|
||||||
|
// Revision not found — fall back to restoring source code and redeploying
|
||||||
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
|
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
|
||||||
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
|
||||||
const appDir = path.join(uploadDir, app.userId, app.id);
|
const appDir = path.join(uploadDir, app.userId, app.id);
|
||||||
const destPath = path.join(appDir, 'source.zip');
|
const destPath = path.join(appDir, 'source.zip');
|
||||||
fs.mkdirSync(appDir, { recursive: true });
|
fs.mkdirSync(appDir, { recursive: true });
|
||||||
fs.copyFileSync(snapshot.appArchivePath, destPath);
|
fs.copyFileSync(snapshot.appArchivePath, destPath);
|
||||||
// Update app codePath in DB
|
|
||||||
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
|
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
|
||||||
details.push('✅ Source code restored');
|
details.push('✅ Source code restored (K8s revision expired — will need redeploy)');
|
||||||
this.logger.log(`Rollback ${snapshotId}: restored source code`);
|
this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`);
|
||||||
|
} else {
|
||||||
|
details.push('⚠️ K8s revision not found and no source archive available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Rollback ${snapshotId}: K8s revision rollback error — ${e.message}`);
|
||||||
|
details.push(`⚠️ K8s rollback failed: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Restore wp-content (WordPress)
|
// 2. Restore wp-content (WordPress) — file-based
|
||||||
if (snapshot.wpContentArchivePath && fs.existsSync(snapshot.wpContentArchivePath)) {
|
if (snapshot.wpContentArchivePath && fs.existsSync(snapshot.wpContentArchivePath)) {
|
||||||
const archiveBuffer = fs.readFileSync(snapshot.wpContentArchivePath);
|
const archiveBuffer = fs.readFileSync(snapshot.wpContentArchivePath);
|
||||||
const result = await this.kubernetesService.restoreWpContent(app, archiveBuffer);
|
const result = await this.kubernetesService.restoreWpContent(app, archiveBuffer);
|
||||||
@@ -325,7 +351,7 @@ export class SnapshotsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Restore database
|
// 3. Restore database — file-based
|
||||||
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
|
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
|
||||||
const dumpBuffer = fs.readFileSync(snapshot.dbDumpPath);
|
const dumpBuffer = fs.readFileSync(snapshot.dbDumpPath);
|
||||||
const result = await this.kubernetesService.restoreDatabaseDump(app, dumpBuffer);
|
const result = await this.kubernetesService.restoreDatabaseDump(app, dumpBuffer);
|
||||||
@@ -345,6 +371,27 @@ export class SnapshotsService {
|
|||||||
return { success: true, details };
|
return { success: true, details };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback using K8s revision directly (without a snapshot).
|
||||||
|
* Fastest rollback — just switches the active ReplicaSet.
|
||||||
|
*/
|
||||||
|
async rollbackToRevision(
|
||||||
|
applicationId: string,
|
||||||
|
userId: string,
|
||||||
|
targetRevision: number,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
return this.kubernetesService.rollbackDeploymentRevision(app, targetRevision);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get K8s deployment revisions for an application.
|
||||||
|
*/
|
||||||
|
async getRevisions(applicationId: string, userId: string) {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
return this.kubernetesService.getDeploymentRevisions(app);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a specific snapshot.
|
* Delete a specific snapshot.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
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 { 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';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
@@ -71,6 +71,7 @@ export default function AppDetailPage() {
|
|||||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||||
const [showSnapshots, setShowSnapshots] = useState(false);
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
||||||
|
const [snapshotTab, setSnapshotTab] = useState<'revisions' | 'snapshots'>('revisions');
|
||||||
|
|
||||||
const { data: app, isLoading } = useQuery<Application>({
|
const { data: app, isLoading } = useQuery<Application>({
|
||||||
queryKey: ['application', appId],
|
queryKey: ['application', appId],
|
||||||
@@ -148,10 +149,39 @@ export default function AppDetailPage() {
|
|||||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||||
queryKey: ['snapshots', appId],
|
queryKey: ['snapshots', appId],
|
||||||
queryFn: () => api.get(`/snapshots/applications/${appId}`).then((r) => r.data),
|
queryFn: () => api.get(`/snapshots/applications/${appId}`).then((r) => r.data),
|
||||||
enabled: showSnapshots,
|
enabled: showSnapshots && snapshotTab === 'snapshots',
|
||||||
refetchInterval: showSnapshots ? 10000 : false,
|
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({
|
const createSnapshotMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
|
mutationFn: () => api.post(`/snapshots/applications/${appId}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -184,7 +214,7 @@ export default function AppDetailPage() {
|
|||||||
const handleRollback = async (snap: AppSnapshot) => {
|
const handleRollback = async (snap: AppSnapshot) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: `Rollback to "${snap.label}"?`,
|
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',
|
confirmText: 'Rollback',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
});
|
});
|
||||||
@@ -1165,7 +1195,7 @@ export default function AppDetailPage() {
|
|||||||
{/* Snapshots & Rollback */}
|
{/* Snapshots & Rollback */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => createSnapshotMutation.mutate()}
|
onClick={() => createSnapshotMutation.mutate()}
|
||||||
@@ -1186,6 +1216,90 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSnapshots && (
|
{showSnapshots && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* ─── 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── File Snapshots Tab ─── */}
|
||||||
|
{snapshotTab === 'snapshots' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Download current live state */}
|
{/* Download current live state */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
@@ -1221,6 +1335,12 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Snapshot list */}
|
||||||
{snapshotsLoading ? (
|
{snapshotsLoading ? (
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">Loading snapshots...</div>
|
<div className="text-center py-8 text-gray-400 text-sm">Loading snapshots...</div>
|
||||||
@@ -1291,7 +1411,6 @@ export default function AppDetailPage() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{snap.status === 'completed' && (
|
{snap.status === 'completed' && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{/* Download dropdown-style buttons */}
|
|
||||||
{snap.appArchivePath && (
|
{snap.appArchivePath && (
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadSnapshotArtifact(snap.id, 'source')}
|
onClick={() => downloadSnapshotArtifact(snap.id, 'source')}
|
||||||
@@ -1323,7 +1442,7 @@ export default function AppDetailPage() {
|
|||||||
onClick={() => handleRollback(snap)}
|
onClick={() => handleRollback(snap)}
|
||||||
disabled={rollbackMutation.isPending}
|
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"
|
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"
|
title="Rollback to this snapshot (uses K8s revision + restores DB/wp-content)"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1347,6 +1466,8 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logs — Pod & Build */}
|
{/* Logs — Pod & Build */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -311,3 +311,17 @@ export interface AppSnapshot {
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: 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