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
+2
View File
@@ -11,6 +11,7 @@ import { KubernetesModule } from './kubernetes/kubernetes.module';
import { BuildModule } from './build/build.module'; import { BuildModule } from './build/build.module';
import { TicketsModule } from './tickets/tickets.module'; import { TicketsModule } from './tickets/tickets.module';
import { BillingModule } from './billing/billing.module'; import { BillingModule } from './billing/billing.module';
import { SnapshotsModule } from './snapshots/snapshots.module';
import configuration from './config/configuration'; import configuration from './config/configuration';
@Module({ @Module({
@@ -60,6 +61,7 @@ import configuration from './config/configuration';
BuildModule, BuildModule,
TicketsModule, TicketsModule,
BillingModule, BillingModule,
SnapshotsModule,
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -6,6 +6,7 @@ import { Deployment } from './entities/deployment.entity';
import { ApplicationsModule } from '../applications/applications.module'; import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { BuildModule } from '../build/build.module'; import { BuildModule } from '../build/build.module';
import { SnapshotsModule } from '../snapshots/snapshots.module';
@Module({ @Module({
imports: [ imports: [
@@ -13,6 +14,7 @@ import { BuildModule } from '../build/build.module';
forwardRef(() => ApplicationsModule), forwardRef(() => ApplicationsModule),
KubernetesModule, KubernetesModule,
BuildModule, BuildModule,
forwardRef(() => SnapshotsModule),
], ],
controllers: [DeploymentsController], controllers: [DeploymentsController],
providers: [DeploymentsService], providers: [DeploymentsService],
@@ -6,6 +6,7 @@ import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service'; import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService } from '../build/build.service'; import { BuildService } from '../build/build.service';
import { DeploymentStatus } from '../common/enums'; import { DeploymentStatus } from '../common/enums';
import { SnapshotsService } from '../snapshots/snapshots.service';
@Injectable() @Injectable()
export class DeploymentsService { export class DeploymentsService {
@@ -18,11 +19,22 @@ export class DeploymentsService {
private applicationsService: ApplicationsService, private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService, private kubernetesService: KubernetesService,
private buildService: BuildService, private buildService: BuildService,
@Inject(forwardRef(() => SnapshotsService))
private snapshotsService: SnapshotsService,
) {} ) {}
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> { async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId); const app = await this.applicationsService.findOne(applicationId, userId);
// Auto-snapshot before deploying (capture current state for rollback)
try {
const version = `v${Date.now()}`;
await this.snapshotsService.createPreDeploySnapshot(app.id, userId, version);
this.logger.log(`Pre-deploy snapshot created for ${app.name}`);
} catch (e: any) {
this.logger.warn(`Pre-deploy snapshot failed for ${app.name}: ${e.message} — continuing deploy`);
}
// Create deployment record // Create deployment record
const deployment = this.deploymentsRepository.create({ const deployment = this.deploymentsRepository.create({
applicationId: app.id, applicationId: app.id,
@@ -173,6 +185,15 @@ export class DeploymentsService {
throw new NotFoundException('No source code available. Upload code or set a git URL first.'); throw new NotFoundException('No source code available. Upload code or set a git URL first.');
} }
// Auto-snapshot before redeploy
try {
const version = `v${Date.now()}`;
await this.snapshotsService.createPreDeploySnapshot(app.id, userId, version);
this.logger.log(`Pre-redeploy snapshot created for ${app.name}`);
} catch (e: any) {
this.logger.warn(`Pre-redeploy snapshot failed for ${app.name}: ${e.message} — continuing`);
}
// Create new deployment record // Create new deployment record
const deployment = this.deploymentsRepository.create({ const deployment = this.deploymentsRepository.create({
applicationId: app.id, applicationId: app.id,
@@ -1093,6 +1093,335 @@ export class KubernetesService implements OnModuleInit {
} }
} }
// ─── Snapshot helpers ───────────────────────────────
/**
* Export (dump) the application database to a local file via a K8s Job.
* Returns the dump as a Buffer, or null on failure.
*/
async exportDatabaseDump(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbName = `${app.name}-db`;
const jobName = `${app.name}-db-dump-${Date.now()}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const defaultDbVer = isPostgres ? '16' : '8.0';
const dbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`;
// Dump command writes to /dump/output.sql inside an emptyDir volume
const command = isPostgres
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbName} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"`]
: ['sh', '-c', `mysqldump -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"`];
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [{
name: 'dump',
image,
command,
env: [
{ name: 'DB_USER', valueFrom: { secretKeyRef: { name: `${app.name}-db-secret`, key: 'username' } } },
{ name: 'DB_PASSWORD', valueFrom: { secretKeyRef: { name: `${app.name}-db-secret`, key: 'password' } } },
],
volumeMounts: [{ name: 'dump-vol', mountPath: '/dump' }],
resources: { requests: { cpu: '100m', memory: '128Mi' }, limits: { cpu: '500m', memory: '512Mi' } },
}],
volumes: [{ name: 'dump-vol', emptyDir: {} }],
},
},
},
};
try {
await batchApi.createNamespacedJob(namespace, job);
} catch (e: any) {
this.logger.error(`Failed to create DB dump job: ${e.message}`);
return { data: null, logs: `Failed to create dump job: ${e.message}` };
}
// Wait for completion (max 5 min)
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const st = await batchApi.readNamespacedJob(jobName, namespace);
if (st.body.status?.succeeded && st.body.status.succeeded > 0) { succeeded = true; break; }
if (st.body.status?.failed && st.body.status.failed > 0) break;
} catch {}
}
// Get dump by exec-ing into the pod and cat-ing the file
let dumpBuffer: Buffer | null = null;
let logs = '';
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
const podName = pods.body.items[0].metadata?.name;
if (podName && succeeded) {
// Use exec to cat the dump file from the pod
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
exec.exec(
namespace, podName, 'dump',
['cat', '/dump/output.sql'],
{
write: (data: string) => { chunks.push(Buffer.from(data)); },
} as any,
null,
{
write: (data: string) => { logs += data; },
} as any,
false,
(status: k8s.V1Status) => {
if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'exec failed'));
},
);
}).catch(() => {
this.logger.warn(`Exec cat failed for ${podName}, trying readNamespacedPodLog`);
});
if (chunks.length > 0) {
dumpBuffer = Buffer.concat(chunks);
}
}
// Fallback: get logs
if (!dumpBuffer && pods.body.items[0].metadata?.name) {
try {
const logRes = await coreApi.readNamespacedPodLog(pods.body.items[0].metadata.name, namespace);
logs = logRes.body || '';
} catch {}
}
}
} catch (e: any) {
this.logger.warn(`Could not retrieve dump: ${e.message}`);
logs = e.message;
}
if (!succeeded) {
return { data: null, logs: logs || 'Dump job failed or timed out' };
}
return { data: dumpBuffer, logs };
}
/**
* Archive the wp-content directory from a WordPress app's PVC via a K8s Job.
* The job creates a tar.gz of /var/www/html/wp-content and we retrieve it via exec.
* Returns the archive as a Buffer, or null on failure.
*/
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-wp-content`;
const jobName = `${app.name}-wp-archive-${Date.now()}`;
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [{
name: 'archiver',
image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && cd /wp-content && tar czf /output/wp-content.tar.gz . && echo "ARCHIVE_DONE"'],
volumeMounts: [
{ name: 'wp-content', mountPath: '/wp-content', readOnly: true },
{ name: 'output', mountPath: '/output' },
],
resources: { requests: { cpu: '100m', memory: '64Mi' }, limits: { cpu: '500m', memory: '256Mi' } },
}],
volumes: [
{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } },
{ name: 'output', emptyDir: {} },
],
},
},
},
};
try {
await batchApi.createNamespacedJob(namespace, job);
} catch (e: any) {
this.logger.error(`Failed to create wp-content archive job: ${e.message}`);
return { data: null, logs: `Failed to create archive job: ${e.message}` };
}
// Wait for completion
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const st = await batchApi.readNamespacedJob(jobName, namespace);
if (st.body.status?.succeeded && st.body.status.succeeded > 0) { succeeded = true; break; }
if (st.body.status?.failed && st.body.status.failed > 0) break;
} catch {}
}
let archiveBuffer: Buffer | null = null;
let logs = '';
if (succeeded) {
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
const podName = pods.body.items[0].metadata?.name;
if (podName) {
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
exec.exec(
namespace, podName, 'archiver',
['cat', '/output/wp-content.tar.gz'],
{
write: (data: string) => { chunks.push(Buffer.from(data)); },
} as any,
null,
{
write: (data: string) => { logs += data; },
} as any,
false,
(status: k8s.V1Status) => {
if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'exec failed'));
},
);
}).catch((err) => {
this.logger.warn(`Exec failed for wp-content archive: ${err.message}`);
});
if (chunks.length > 0) {
archiveBuffer = Buffer.concat(chunks);
}
}
}
} catch (e: any) {
logs = e.message;
}
} else {
logs = 'Archive job failed or timed out';
}
return { data: archiveBuffer, logs };
}
/**
* Restore wp-content from a tar.gz archive into the WordPress PVC.
*/
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-wp-content`;
const jobName = `${app.name}-wp-restore-${Date.now()}`;
const secretName = `${jobName}-archive`;
// Store archive in a secret
const archiveSecret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: secretName, namespace },
data: { 'wp-content.tar.gz': archiveBuffer.toString('base64') },
};
try {
await coreApi.createNamespacedSecret(namespace, archiveSecret);
} catch (e: any) {
return { success: false, logs: `Failed to create archive secret: ${e.message}` };
}
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [{
name: 'restore',
image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && rm -rf /wp-content/* && cd /wp-content && tar xzf /archive/wp-content.tar.gz && echo "RESTORE_DONE"'],
volumeMounts: [
{ name: 'wp-content', mountPath: '/wp-content' },
{ name: 'archive', mountPath: '/archive', readOnly: true },
],
resources: { requests: { cpu: '100m', memory: '64Mi' }, limits: { cpu: '500m', memory: '256Mi' } },
}],
volumes: [
{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } },
{ name: 'archive', secret: { secretName } },
],
},
},
},
};
try {
await batchApi.createNamespacedJob(namespace, job);
} catch (e: any) {
try { await coreApi.deleteNamespacedSecret(secretName, namespace); } catch {}
return { success: false, logs: `Failed to create restore job: ${e.message}` };
}
// Wait
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
let failed = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const st = await batchApi.readNamespacedJob(jobName, namespace);
if (st.body.status?.succeeded && st.body.status.succeeded > 0) { succeeded = true; break; }
if (st.body.status?.failed && st.body.status.failed > 0) { failed = true; break; }
} catch {}
}
let logs = '';
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0 && pods.body.items[0].metadata?.name) {
const logRes = await coreApi.readNamespacedPodLog(pods.body.items[0].metadata.name, namespace);
logs = logRes.body || '';
}
} catch {}
try { await coreApi.deleteNamespacedSecret(secretName, namespace); } catch {}
return { success: succeeded && !failed, logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out') };
}
private generatePassword(length = 24): string { private generatePassword(length = 24): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%'; const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
let password = ''; let password = '';
@@ -0,0 +1,79 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Application } from '../../applications/entities/application.entity';
export enum SnapshotType {
PRE_DEPLOY = 'pre_deploy', // Automatic snapshot before each deploy
MANUAL = 'manual', // User-triggered manual snapshot
}
export enum SnapshotStatus {
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
FAILED = 'failed',
}
@Entity('snapshots')
export class AppSnapshot {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: SnapshotType, default: SnapshotType.MANUAL })
type: SnapshotType;
@Column({ type: 'enum', enum: SnapshotStatus, default: SnapshotStatus.IN_PROGRESS })
status: SnapshotStatus;
@Column({ nullable: true })
label: string; // Human-readable label e.g. "Before deploy v1712345678"
// ─── App snapshot data ────────────────────────────
@Column({ nullable: true })
appArchivePath: string; // Local path to archived app source (zip)
@Column({ nullable: true })
wpContentArchivePath: string; // Local path to archived wp-content (tar.gz from PVC)
@Column({ nullable: true })
imageTag: string; // Docker image tag at time of snapshot
// ─── DB snapshot data ─────────────────────────────
@Column({ nullable: true })
dbDumpPath: string; // Local path to DB dump file
@Column({ default: false })
hasDatabase: boolean;
// ─── Metadata ─────────────────────────────────────
@Column({ type: 'bigint', nullable: true })
appArchiveSize: number; // bytes
@Column({ type: 'bigint', nullable: true })
dbDumpSize: number; // bytes
@Column({ type: 'bigint', nullable: true })
wpContentSize: number; // bytes
@Column({ nullable: true })
errorMessage: string;
// ─── Relations ────────────────────────────────────
@ManyToOne(() => Application, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column()
applicationId: string;
@Column()
createdBy: string; // userId
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,149 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
Res,
UseGuards,
Request,
Logger,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import * as fs from 'fs';
import { SnapshotsService } from './snapshots.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { SnapshotType } from './entities/snapshot.entity';
@ApiTags('Snapshots')
@ApiBearerAuth()
@Controller('snapshots')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class SnapshotsController {
private readonly logger = new Logger(SnapshotsController.name);
constructor(private readonly snapshotsService: SnapshotsService) {}
// ─── Snapshot CRUD ──────────────────────────────────
@Post('applications/:appId')
@ApiOperation({ summary: 'Create a manual snapshot of the current app state' })
async createSnapshot(
@Param('appId') appId: string,
@Request() req: any,
@Query('label') label?: string,
) {
const snapshot = await this.snapshotsService.createSnapshot(
appId,
req.user.id,
SnapshotType.MANUAL,
label,
);
return { message: 'Snapshot creation started', snapshot };
}
@Get('applications/:appId')
@ApiOperation({ summary: 'List all snapshots for an application (max 10)' })
async listSnapshots(@Param('appId') appId: string, @Request() req: any) {
return this.snapshotsService.listSnapshots(appId, req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get snapshot details' })
async getSnapshot(@Param('id') id: string, @Request() req: any) {
return this.snapshotsService.findOne(id, req.user.id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a specific snapshot' })
async deleteSnapshot(@Param('id') id: string, @Request() req: any) {
await this.snapshotsService.deleteSnapshot(id, req.user.id);
return { message: 'Snapshot deleted' };
}
// ─── Rollback ───────────────────────────────────────
@Post(':id/rollback')
@ApiOperation({ summary: 'Rollback application to a specific snapshot' })
async rollback(@Param('id') id: string, @Request() req: any) {
const result = await this.snapshotsService.rollbackToSnapshot(id, req.user.id);
return result;
}
// ─── Download snapshot artifacts ────────────────────
@Get(':id/download/source')
@ApiOperation({ summary: 'Download source code from a snapshot' })
async downloadSource(@Param('id') id: string, @Request() req: any, @Res() res: Response) {
const { filePath, fileName } = await this.snapshotsService.getDownloadPath(id, req.user.id, 'source');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Type', 'application/zip');
const stream = fs.createReadStream(filePath);
stream.pipe(res);
}
@Get(':id/download/wp-content')
@ApiOperation({ summary: 'Download wp-content archive from a snapshot' })
async downloadWpContent(@Param('id') id: string, @Request() req: any, @Res() res: Response) {
const { filePath, fileName } = await this.snapshotsService.getDownloadPath(id, req.user.id, 'wp-content');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Type', 'application/gzip');
const stream = fs.createReadStream(filePath);
stream.pipe(res);
}
@Get(':id/download/database')
@ApiOperation({ summary: 'Download database dump from a snapshot' })
async downloadDatabase(@Param('id') id: string, @Request() req: any, @Res() res: Response) {
const { filePath, fileName } = await this.snapshotsService.getDownloadPath(id, req.user.id, 'database');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Type', 'application/sql');
const stream = fs.createReadStream(filePath);
stream.pipe(res);
}
// ─── Download CURRENT live state ────────────────────
@Get('applications/:appId/current/source')
@ApiOperation({ summary: 'Download the current source code of an application' })
async downloadCurrentSource(@Param('appId') appId: string, @Request() req: any, @Res() res: Response) {
const result = await this.snapshotsService.downloadCurrentSource(appId, req.user.id);
if (!result) {
res.status(404).json({ message: 'No source code available' });
return;
}
res.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`);
res.setHeader('Content-Type', 'application/zip');
const stream = fs.createReadStream(result.filePath);
stream.pipe(res);
}
@Get('applications/:appId/current/wp-content')
@ApiOperation({ summary: 'Download the current live wp-content from a WordPress app' })
async downloadCurrentWpContent(@Param('appId') appId: string, @Request() req: any, @Res() res: Response) {
const data = await this.snapshotsService.downloadCurrentWpContent(appId, req.user.id);
if (!data) {
res.status(404).json({ message: 'Could not retrieve wp-content' });
return;
}
res.setHeader('Content-Disposition', `attachment; filename="wp-content-live.tar.gz"`);
res.setHeader('Content-Type', 'application/gzip');
res.send(data);
}
@Get('applications/:appId/current/database')
@ApiOperation({ summary: 'Download the current live database dump' })
async downloadCurrentDatabase(@Param('appId') appId: string, @Request() req: any, @Res() res: Response) {
const data = await this.snapshotsService.downloadCurrentDatabase(appId, req.user.id);
if (!data) {
res.status(404).json({ message: 'Could not dump database' });
return;
}
res.setHeader('Content-Disposition', `attachment; filename="database-live.sql"`);
res.setHeader('Content-Type', 'application/sql');
res.send(data);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SnapshotsService } from './snapshots.service';
import { SnapshotsController } from './snapshots.controller';
import { AppSnapshot } from './entities/snapshot.entity';
import { ApplicationsModule } from '../applications/applications.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({
imports: [
TypeOrmModule.forFeature([AppSnapshot]),
forwardRef(() => ApplicationsModule),
KubernetesModule,
],
controllers: [SnapshotsController],
providers: [SnapshotsService],
exports: [SnapshotsService],
})
export class SnapshotsModule {}
+369
View File
@@ -0,0 +1,369 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { AppRuntime, DatabaseType } from '../common/enums';
const MAX_SNAPSHOTS = 10;
@Injectable()
export class SnapshotsService {
private readonly logger = new Logger(SnapshotsService.name);
constructor(
@InjectRepository(AppSnapshot)
private snapshotsRepo: Repository<AppSnapshot>,
@Inject(forwardRef(() => ApplicationsService))
private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService,
private configService: ConfigService,
) {}
/**
* Create a snapshot of the current state of an application.
* Captures: source code zip, wp-content (for WordPress), and DB dump.
*/
async createSnapshot(
applicationId: string,
userId: string,
type: SnapshotType = SnapshotType.MANUAL,
label?: string,
): Promise<AppSnapshot> {
const app = await this.applicationsService.findOne(applicationId, userId);
const snapshot = this.snapshotsRepo.create({
applicationId: app.id,
createdBy: userId,
type,
status: SnapshotStatus.IN_PROGRESS,
label: label || `Snapshot ${new Date().toLocaleString()}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
} as Partial<AppSnapshot>);
const saved = await this.snapshotsRepo.save(snapshot);
// Run snapshot capture async
this.captureSnapshot(saved.id, app).catch((err) => {
this.logger.error(`Snapshot ${saved.id} capture failed: ${err.message}`);
});
return saved;
}
/**
* Create a pre-deploy snapshot (called automatically before each deploy).
* Does NOT require userId ownership check — called internally.
*/
async createPreDeploySnapshot(applicationId: string, userId: string, version: string): Promise<AppSnapshot> {
// Use findOne without userId to bypass ownership (internal call)
const app = await this.applicationsService.findOne(applicationId);
const snapshot = this.snapshotsRepo.create({
applicationId: app.id,
createdBy: userId,
type: SnapshotType.PRE_DEPLOY,
status: SnapshotStatus.IN_PROGRESS,
label: `Before deploy ${version}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
} as Partial<AppSnapshot>);
const saved = await this.snapshotsRepo.save(snapshot);
// Run snapshot capture (await it for pre-deploy to ensure it completes before deploy)
await this.captureSnapshot(saved.id, app);
return saved;
}
private async captureSnapshot(snapshotId: string, app: any): Promise<void> {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId);
fs.mkdirSync(snapshotDir, { recursive: true });
const updates: Partial<AppSnapshot> = {};
try {
// 1. Copy current source code zip
if (app.codePath && fs.existsSync(app.codePath)) {
const destPath = path.join(snapshotDir, 'source.zip');
fs.copyFileSync(app.codePath, destPath);
updates.appArchivePath = destPath;
updates.appArchiveSize = fs.statSync(destPath).size;
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
}
// 2. Archive wp-content for WordPress apps
if (app.runtime === AppRuntime.WORDPRESS) {
try {
const { data, logs } = await this.kubernetesService.archiveWpContent(app);
if (data && data.length > 0) {
const wpPath = path.join(snapshotDir, 'wp-content.tar.gz');
fs.writeFileSync(wpPath, data);
updates.wpContentArchivePath = wpPath;
updates.wpContentSize = data.length;
this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`);
} else {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`);
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`);
}
}
// 3. Dump database
if (app.databaseType !== DatabaseType.NONE) {
try {
const { data, logs } = await this.kubernetesService.exportDatabaseDump(app);
if (data && data.length > 0) {
const dbPath = path.join(snapshotDir, 'database.sql');
fs.writeFileSync(dbPath, data);
updates.dbDumpPath = dbPath;
updates.dbDumpSize = data.length;
this.logger.log(`Snapshot ${snapshotId}: dumped database (${(data.length / 1024).toFixed(1)} KB)`);
} else {
this.logger.warn(`Snapshot ${snapshotId}: DB dump empty — ${logs}`);
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: DB dump failed — ${e.message}`);
}
}
updates.status = SnapshotStatus.COMPLETED;
} catch (error: any) {
updates.status = SnapshotStatus.FAILED;
updates.errorMessage = error.message;
this.logger.error(`Snapshot ${snapshotId} failed: ${error.message}`);
}
await this.snapshotsRepo.update(snapshotId, updates);
// Prune old snapshots (keep max 10)
await this.pruneSnapshots(app.id);
}
/**
* Keep only the latest MAX_SNAPSHOTS per application. Delete older ones + their files.
*/
private async pruneSnapshots(applicationId: string): Promise<void> {
const all = await this.snapshotsRepo.find({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (all.length <= MAX_SNAPSHOTS) return;
const toDelete = all.slice(MAX_SNAPSHOTS);
for (const snap of toDelete) {
this.deleteSnapshotFiles(snap);
await this.snapshotsRepo.remove(snap);
}
this.logger.log(`Pruned ${toDelete.length} old snapshot(s) for app ${applicationId}`);
}
private deleteSnapshotFiles(snap: AppSnapshot): void {
for (const filePath of [snap.appArchivePath, snap.wpContentArchivePath, snap.dbDumpPath]) {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch {}
}
}
// Try to remove the snapshot directory
if (snap.appArchivePath) {
const dir = path.dirname(snap.appArchivePath);
try {
if (fs.existsSync(dir)) fs.rmdirSync(dir);
} catch {}
}
}
/**
* List snapshots for an application (newest first).
*/
async listSnapshots(applicationId: string, userId: string): Promise<AppSnapshot[]> {
// Verify user has access
await this.applicationsService.findOne(applicationId, userId);
return this.snapshotsRepo.find({
where: { applicationId },
order: { createdAt: 'DESC' },
take: MAX_SNAPSHOTS,
});
}
/**
* Get a single snapshot (with ownership check).
*/
async findOne(snapshotId: string, userId: string): Promise<AppSnapshot> {
const snapshot = await this.snapshotsRepo.findOne({
where: { id: snapshotId },
relations: ['application'],
});
if (!snapshot) throw new NotFoundException('Snapshot not found');
// Verify ownership
await this.applicationsService.findOne(snapshot.applicationId, userId);
return snapshot;
}
/**
* Download a snapshot artifact (source, wp-content, or database).
*/
async getDownloadPath(
snapshotId: string,
userId: string,
artifact: 'source' | 'wp-content' | 'database',
): Promise<{ filePath: string; fileName: string }> {
const snapshot = await this.findOne(snapshotId, userId);
let filePath: string | null = null;
let fileName = '';
switch (artifact) {
case 'source':
filePath = snapshot.appArchivePath;
fileName = `${snapshot.applicationId}-source-${snapshot.id.slice(0, 8)}.zip`;
break;
case 'wp-content':
filePath = snapshot.wpContentArchivePath;
fileName = `${snapshot.applicationId}-wp-content-${snapshot.id.slice(0, 8)}.tar.gz`;
break;
case 'database':
filePath = snapshot.dbDumpPath;
fileName = `${snapshot.applicationId}-database-${snapshot.id.slice(0, 8)}.sql`;
break;
}
if (!filePath || !fs.existsSync(filePath)) {
throw new NotFoundException(`Snapshot artifact "${artifact}" not found or has been deleted`);
}
return { filePath, fileName };
}
/**
* Download the CURRENT live state of the app (not from a snapshot).
* Creates a temporary archive of the current source code.
*/
async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.codePath && fs.existsSync(app.codePath)) {
return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` };
}
return null;
}
/**
* Download the current live wp-content from the running WordPress app.
*/
async downloadCurrentWpContent(applicationId: string, userId: string): Promise<Buffer | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.runtime !== AppRuntime.WORDPRESS) {
throw new BadRequestException('Only WordPress applications have wp-content');
}
const { data } = await this.kubernetesService.archiveWpContent(app);
return data;
}
/**
* Download the current live database dump.
*/
async downloadCurrentDatabase(applicationId: string, userId: string): Promise<Buffer | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.databaseType === DatabaseType.NONE) {
throw new BadRequestException('This application does not have a database');
}
const { data } = await this.kubernetesService.exportDatabaseDump(app);
return data;
}
/**
* Rollback an application to a specific snapshot.
* Restores: source code, wp-content (WordPress), and database.
*/
async rollbackToSnapshot(snapshotId: string, userId: string): Promise<{ success: boolean; details: string[] }> {
const snapshot = await this.findOne(snapshotId, userId);
if (snapshot.status !== SnapshotStatus.COMPLETED) {
throw new BadRequestException('Cannot rollback to an incomplete or failed snapshot');
}
const app = await this.applicationsService.findOne(snapshot.applicationId, userId);
const details: string[] = [];
// 1. Restore source code
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
const destPath = path.join(appDir, 'source.zip');
fs.mkdirSync(appDir, { recursive: true });
fs.copyFileSync(snapshot.appArchivePath, destPath);
// Update app codePath in DB
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
details.push('✅ Source code restored');
this.logger.log(`Rollback ${snapshotId}: restored source code`);
}
// 2. Restore wp-content (WordPress)
if (snapshot.wpContentArchivePath && fs.existsSync(snapshot.wpContentArchivePath)) {
const archiveBuffer = fs.readFileSync(snapshot.wpContentArchivePath);
const result = await this.kubernetesService.restoreWpContent(app, archiveBuffer);
if (result.success) {
details.push('✅ wp-content restored');
this.logger.log(`Rollback ${snapshotId}: restored wp-content`);
} else {
details.push(`⚠️ wp-content restore failed: ${result.logs}`);
this.logger.warn(`Rollback ${snapshotId}: wp-content restore failed`);
}
}
// 3. Restore database
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
const dumpBuffer = fs.readFileSync(snapshot.dbDumpPath);
const result = await this.kubernetesService.restoreDatabaseDump(app, dumpBuffer);
if (result.success) {
details.push('✅ Database restored');
this.logger.log(`Rollback ${snapshotId}: restored database`);
} else {
details.push(`⚠️ Database restore failed: ${result.logs}`);
this.logger.warn(`Rollback ${snapshotId}: database restore failed`);
}
}
if (details.length === 0) {
details.push('⚠️ No artifacts found in this snapshot to restore');
}
return { success: true, details };
}
/**
* Delete a specific snapshot.
*/
async deleteSnapshot(snapshotId: string, userId: string): Promise<void> {
const snapshot = await this.findOne(snapshotId, userId);
this.deleteSnapshotFiles(snapshot);
await this.snapshotsRepo.remove(snapshot);
this.logger.log(`Deleted snapshot ${snapshotId}`);
}
/**
* Delete all snapshots for an application (called when app is deleted).
*/
async deleteAllForApplication(applicationId: string): Promise<void> {
const all = await this.snapshotsRepo.find({ where: { applicationId } });
for (const snap of all) {
this.deleteSnapshotFiles(snap);
}
await this.snapshotsRepo.delete({ applicationId });
this.logger.log(`Deleted all snapshots for app ${applicationId}`);
}
}
+291 -2
View File
@@ -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 } from '@/types'; import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot } 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 } 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'; import { useConfirm } from '@/components/confirm-modal';
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
@@ -70,6 +70,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 { data: app, isLoading } = useQuery<Application>({ const { data: app, isLoading } = useQuery<Application>({
queryKey: ['application', appId], 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 // Sync form when resource data loads
useEffect(() => { useEffect(() => {
if (resourceUsage?.configured) { if (resourceUsage?.configured) {
@@ -1059,6 +1162,192 @@ export default function AppDetailPage() {
)} )}
</div> </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 */} {/* Logs — Pod & Build */}
<div className="card"> <div className="card">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
+24
View File
@@ -287,3 +287,27 @@ export interface CostBreakdown {
yearly: number; yearly: number;
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[]; breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
} }
// ─── Snapshot / Rollback types ──────────────────────
export type SnapshotType = 'pre_deploy' | 'manual';
export type SnapshotStatus = 'in_progress' | 'completed' | 'failed';
export interface AppSnapshot {
id: string;
type: SnapshotType;
status: SnapshotStatus;
label?: string;
appArchivePath?: string;
wpContentArchivePath?: string;
imageTag?: string;
dbDumpPath?: string;
hasDatabase: boolean;
appArchiveSize?: number;
dbDumpSize?: number;
wpContentSize?: number;
errorMessage?: string;
applicationId: string;
createdBy: string;
createdAt: string;
}
File diff suppressed because one or more lines are too long