mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
feat/add backup overview
This commit is contained in:
64
src/app/backups/backup-detail-overlay.tsx
Normal file
64
src/app/backups/backup-detail-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/app/backups/backups-table.tsx
Normal file
44
src/app/backups/backups-table.tsx
Normal 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
42
src/app/backups/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
src/components/ui/alert.tsx
Normal file
60
src/components/ui/alert.tsx
Normal 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 }
|
||||
@@ -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);
|
||||
|
||||
17
src/shared/model/backup-info.model.ts
Normal file
17
src/shared/model/backup-info.model.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user