feat/add backup overview

This commit is contained in:
biersoeckli
2025-01-09 14:50:47 +00:00
parent b743b387d5
commit bd2da6ce84
9 changed files with 380 additions and 12 deletions

View File

@@ -0,0 +1,64 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import React from "react";
import { BackupInfoModel } from "@/shared/model/backup-info.model";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import { formatDateTime } from "@/frontend/utils/format.utils";
export function BackupDetailDialog({
backupInfo,
children
}: {
backupInfo: BackupInfoModel;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = React.useState(false);
return (
<Dialog open={isOpen} onOpenChange={(isO) => {
setIsOpen(isO);
}}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Backups</DialogTitle>
<DialogDescription>
<span className="font-semibold">App:</span> {backupInfo.appName}<br />
<span className="font-semibold">Mount Path:</span> {backupInfo.mountPath}<br />
For this backup schedule the latest {backupInfo.backupRetention} versions are kept.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh]">
<Table>
<TableCaption>{backupInfo.backups.length} Backups</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backupInfo.backups.map((item, index) => (
<TableRow key={index}>
<TableCell>{formatDateTime(item.backupDate, true)}</TableCell>
<TableCell>{item.sizeBytes ? KubeSizeConverter.convertBytesToReadableSize(item.sizeBytes) : 'unknown'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { Button } from "@/components/ui/button";
import { SimpleDataTable } from "@/components/custom/simple-data-table";
import { formatDateTime } from "@/frontend/utils/format.utils";
import { List } from "lucide-react";
import { BackupInfoModel } from "@/shared/model/backup-info.model";
import { BackupDetailDialog } from "./backup-detail-overlay";
export default function BackupsTable({ data }: { data: BackupInfoModel[] }) {
return <>
<SimpleDataTable columns={[
['projectId', 'Project ID', false],
['projectName', 'Project', true],
['appName', 'App', true],
['appId', 'App ID', false],
['backupVolumeId', 'Backup Volume ID', false],
['volumeId', 'Volume ID', false],
['mountPath', 'Mount Path', true],
['backupRetention', 'Retention', false],
['backupsCount', 'Backups', true, (item) => `${item.backups.length} backups`],
['item.backups[0].backupDate', 'Last Backup', true, (item) => formatDateTime(item.backups[0].backupDate)],
]}
data={data}
actionCol={(item) =>
<>
<div className="flex">
<div className="flex-1"></div>
<BackupDetailDialog backupInfo={item}>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">show backups</span>
<List className="h-4 w-4" />
</Button>
</BackupDetailDialog>
</div>
</>}
/>
</>
}

42
src/app/backups/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
'use server'
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import PageTitle from "@/components/custom/page-title";
import backupService from "@/server/services/standalone-services/backup.service";
import BackupsTable from "./backups-table";
import { AlertCircle } from "lucide-react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
export default async function BackupsPage() {
await getAuthUserSession();
const {
backupInfoModels,
backupsVolumesWithoutActualBackups
} = await backupService.getBackupsForAllS3Targets();
return (
<div className="flex-1 space-y-4 pt-6">
<PageTitle
title={'Backups'}
subtitle={`View all backups wich are stored in all S3 Target destinations. If a backup exists from an app wich doesnt exist anymore, it will be shown as orphaned.`}>
</PageTitle>
<div className="space-y-6">
{backupsVolumesWithoutActualBackups.length > 0 && <Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Apps without Backup</AlertTitle>
<AlertDescription>
The following apps have backups configured, but until now no backups were created for them:<br />
{backupsVolumesWithoutActualBackups.map((item) => `${item.volume.app.name} (mount: ${item.volume.containerMountPath})`).join(', ')}
</AlertDescription>
</Alert>}
<BackupsTable data={backupInfoModels} />
</div>
</div>
)
}

View File

@@ -9,18 +9,14 @@ import {
} from '@/components/ui/card';
import { useEffect, useState } from 'react';
import { Actions } from '@/frontend/utils/nextjs-actions.utils';
import { getMonitoringForAllApps, getVolumeMonitoringUsage } from './actions';
import { getMonitoringForAllApps } from './actions';
import { toast } from 'sonner';
import FullLoadingSpinner from '@/components/ui/full-loading-spinnter';
import { AppVolumeMonitoringUsageModel } from '@/shared/model/app-volume-monitoring-usage.model';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { KubeSizeConverter } from '@/shared/utils/kubernetes-size-converter.utils';
import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Progress } from "@/components/ui/progress"
import dataAccess from '@/server/adapter/db.client';
import { ProgressIndicator } from '@radix-ui/react-progress';
import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model';
export default function AppRessourceMonitoring({
@@ -51,6 +47,9 @@ export default function AppRessourceMonitoring({
if (!updatedAppUsage) {
return <Card>
<CardHeader>
<CardTitle>App Ressource Usage</CardTitle>
</CardHeader>
<CardContent>
<FullLoadingSpinner />
</CardContent>

View File

@@ -19,8 +19,6 @@ import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Progress } from "@/components/ui/progress"
import dataAccess from '@/server/adapter/db.client';
import { ProgressIndicator } from '@radix-ui/react-progress';
type AppVolumeMonitoringUsageExtendedModel = AppVolumeMonitoringUsageModel & {
usedPercentage: number;
@@ -77,6 +75,9 @@ export default function AppVolumeMonitoring({
if (!updatedVolumeUsage) {
return <Card>
<CardHeader>
<CardTitle>App Volumes Capacity</CardTitle>
</CardHeader>
<CardContent>
<FullLoadingSpinner />
</CardContent>

View File

@@ -17,7 +17,7 @@ import {
SidebarMenuAction,
useSidebar
} from "@/components/ui/sidebar"
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, Info, Plus, Server, Settings, Settings2, User } from "lucide-react"
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, History, Info, Plus, Server, Settings, Settings2, User } from "lucide-react"
import Link from "next/link"
import { EditProjectDialog } from "./projects/edit-project-dialog"
import { SidebarLogoutButton } from "./sidebar-logout-button"
@@ -225,6 +225,26 @@ export function SidebarCient({
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={{
children: 'Monitoring',
hidden: open,
}}
isActive={path.startsWith('/backups')}>
<Link href="/backups">
<History />
<span>Backups</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/frontend/utils/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -6,9 +6,17 @@ import s3Service from "../aws-s3.service";
import scheduleService from "./schedule.service";
import standalonePodService from "./standalone-pod.service";
import { ListUtils } from "../../../shared/utils/list.utils";
import { S3Target, VolumeBackup } from "@prisma/client";
import { BackupEntry, BackupInfoModel } from "../../../shared/model/backup-info.model";
const s3BucketPrefix = 'quickstack-backups';
class BackupService {
folderPathForVolumeBackup(appId: string, backupVolumeId: string) {
return `${s3BucketPrefix}/${appId}/${backupVolumeId}`;
}
async registerAllBackups() {
const allVolumeBackups = await dataAccess.client.volumeBackup.findMany();
console.log(`Deregistering existing backup schedules...`);
@@ -40,6 +48,119 @@ class BackupService {
}
}
async getBackupsForAllS3Targets() {
const s3Targets = await dataAccess.client.s3Target.findMany();
const returnValFromAllS3Targets = await Promise.all(s3Targets.map(s3Target =>
this.getBackupsFromS3Target(s3Target)));
const backupInfoModels = returnValFromAllS3Targets.map(x => x.backupInfoModels).flat();
backupInfoModels.sort((a, b) => {
if (a.projectName === b.projectName) {
return a.appName.localeCompare(b.appName);
}
return a.projectName.localeCompare(b.projectName);
});
const backupsVolumesWithoutActualBackups = returnValFromAllS3Targets.map(x => x.backupsVolumesWithoutActualBackups).flat();
return {
backupInfoModels,
backupsVolumesWithoutActualBackups
};
}
async getBackupsFromS3Target(s3Target: S3Target) {
const defaultInfoIfAppWasDeleted = 'orphaned';
const volumeBackups = await dataAccess.client.volumeBackup.findMany({
include: {
volume: {
include: {
app: {
include: {
project: true
}
}
}
},
target: true
}
});
const backupData = await this.listAndParseBackupFiles(s3Target);
const groupedBackupInfo = ListUtils.groupBy(backupData, x => x.backupVolumeId);
const backupInfoModels: BackupInfoModel[] = [];
for (let [backupVolumeId, backups] of Array.from(groupedBackupInfo.entries())) {
const volumeBackup = volumeBackups.find(vb => vb.id === backupVolumeId);
const backupEntries: BackupEntry[] = backups.map(b => ({
backupDate: b.backupDate,
key: b.key ?? '',
sizeBytes: b.sizeBytes
}));
backupEntries.sort((a, b) => b.backupDate.getTime() - a.backupDate.getTime());
backupInfoModels.push({
projectId: volumeBackup?.volume.app.projectId ?? defaultInfoIfAppWasDeleted,
projectName: volumeBackup?.volume.app.project.name ?? defaultInfoIfAppWasDeleted,
appName: volumeBackup?.volume.app.name ?? defaultInfoIfAppWasDeleted,
appId: backups[0].appId,
backupVolumeId: backups[0].backupVolumeId,
backupRetention: volumeBackup?.retention ?? 0,
volumeId: volumeBackup?.id ?? defaultInfoIfAppWasDeleted,
mountPath: volumeBackup?.volume.containerMountPath ?? defaultInfoIfAppWasDeleted,
backups: backupEntries
});
}
const backupsVolumesWithoutActualBackups = volumeBackups.filter(vb => !backupInfoModels.find(x => x.backupVolumeId === vb.id));
backupInfoModels.sort((a, b) => {
if (a.projectName === b.projectName) {
return a.appName.localeCompare(b.appName);
}
return a.projectName.localeCompare(b.projectName);
});
return { backupInfoModels, backupsVolumesWithoutActualBackups };
}
private async listAndParseBackupFiles(s3Target: { id: string; createdAt: Date; updatedAt: Date; name: string; bucketName: string; endpoint: string; region: string; accessKeyId: string; secretKey: string; }) {
const fileKeys = await s3Service.listFiles(s3Target);
const backupData = fileKeys.filter(x => {
if (!x.Key) {
return false;
}
return x.Key.startsWith(s3BucketPrefix);
}).map(fileKey => {
try {
const splittedKey = fileKey.Key?.split('/');
if (!splittedKey || splittedKey.length < 3) {
return undefined;
}
const appId = splittedKey[1];
const backupVolumeId = splittedKey[2];
const backupDate = new Date(splittedKey[3].replace('.tar.gz', ''));
return {
appId,
backupVolumeId,
backupDate,
key: fileKey.Key,
sizeBytes: fileKey.Size
};
} catch (e) {
console.error(`Error during read information for backup for key ${fileKey}`);
console.error(e);
}
}).filter(x => !!x);
return backupData;
}
async runBackupForVolume(backupVolumeId: string) {
console.log(`Running backup for backupVolume ${backupVolumeId}`);
@@ -75,21 +196,21 @@ class BackupService {
console.log(`Downloading data from pod ${firstPod.podName} ${volume.containerMountPath} to ${downloadPath}`);
await standalonePodService.cpFromPod(projectId, firstPod.podName, firstPod.containerName, volume.containerMountPath, downloadPath);
// uploac backup
// upload backup
console.log(`Uploading backup to S3`);
const now = new Date();
const nowString = now.toISOString();
await s3Service.uploadFile(backupVolume.target, downloadPath,
`${appId}/${backupVolumeId}/${nowString}.tar.gz`, 'application/gzip', 'binary');
`${this.folderPathForVolumeBackup(appId, backupVolumeId)}/${nowString}.tar.gz`, 'application/gzip', 'binary');
// delete files wich are nod needed anymore (by retention)
console.log(`Deleting old backups`);
const files = await s3Service.listFiles(backupVolume.target);
const filesFromThisBackup = files.filter(f => f.Key?.startsWith(`${appId}/${backupVolumeId}/`)).map(f => ({
const filesFromThisBackup = files.filter(f => f.Key?.startsWith(`${this.folderPathForVolumeBackup(appId, backupVolumeId)}/`)).map(f => ({
date: new Date((f.Key ?? '')
.replace(`${appId}/${backupVolumeId}/`, '')
.replace(`${this.folderPathForVolumeBackup(appId, backupVolumeId)}/`, '')
.replace('.tar.gz', '')),
key: f.Key
})).filter(f => !isNaN(f.date.getTime()) && !!f.key);

View File

@@ -0,0 +1,17 @@
export interface BackupInfoModel {
projectId: string;
projectName: string;
appName: string;
appId: string;
backupVolumeId: string;
volumeId: string;
mountPath: string;
backupRetention: number;
backups: BackupEntry[]
}
export interface BackupEntry {
key: string;
backupDate: Date;
sizeBytes?: number;
}