Enhance backup functionality by introducing support for compose backups. Update backup schema to include serviceName and backupType fields. Modify related components to handle new backup types and integrate service selection for compose backups. Update API routes and database schema accordingly.

This commit is contained in:
Mauricio Siu 2025-04-27 20:17:49 -06:00
parent 7ae3ff22ee
commit 2ea2605ab1
19 changed files with 6005 additions and 69 deletions

View File

@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
@ -31,42 +32,59 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { DatabaseZap, PlusIcon, RefreshCw } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
type CacheType = "cache" | "fetch";
const AddPostgresBackup1Schema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
// .regex(
// new RegExp(
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
// ),
// "Invalid Cron",
// ),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
});
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props {
databaseId: string;
id: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void;
backupType: "database" | "compose";
}
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
export const AddBackup = ({
id,
databaseType,
refetch,
backupType = "database",
}: Props) => {
const { data, isLoading } = api.destination.all.useQuery();
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
api.backup.create.useMutation();
@ -79,10 +97,28 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose",
},
);
useEffect(() => {
form.reset({
database: databaseType === "web-server" ? "dokploy" : "",
@ -91,32 +127,45 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
}, [form, form.reset, form.formState.isSubmitSuccessful, databaseType]);
const onSubmit = async (data: AddPostgresBackup) => {
if (backupType === "compose" && !data.serviceName) {
form.setError("serviceName", {
type: "manual",
message: "Service name is required for compose backups",
});
return;
}
const getDatabaseId =
databaseType === "postgres"
backupType === "compose"
? {
postgresId: databaseId,
composeId: id,
}
: databaseType === "mariadb"
: databaseType === "postgres"
? {
mariadbId: databaseId,
postgresId: id,
}
: databaseType === "mysql"
: databaseType === "mariadb"
? {
mysqlId: databaseId,
mariadbId: id,
}
: databaseType === "mongo"
: databaseType === "mysql"
? {
mongoId: databaseId,
mysqlId: id,
}
: databaseType === "web-server"
: databaseType === "mongo"
? {
userId: databaseId,
mongoId: id,
}
: undefined;
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
@ -125,8 +174,10 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType,
databaseType: databaseType,
serviceName: data.serviceName,
...getDatabaseId,
backupType,
})
.then(async () => {
toast.success("Backup Created");
@ -157,6 +208,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="destinationId"
@ -232,6 +288,110 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
</FormItem>
)}
/>
{backupType === "compose" && (
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<SelectItem value="none" disabled>
Empty
</SelectItem>
)}
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="database"

View File

@ -46,7 +46,7 @@ import type { ServiceType } from "../../application/advanced/show-resources";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
databaseId: string;
id: string;
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
serverId?: string | null;
}
@ -85,11 +85,7 @@ const formatBytes = (bytes: number): string => {
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
export const RestoreBackup = ({
databaseId,
databaseType,
serverId,
}: Props) => {
export const RestoreBackup = ({ id, databaseType, serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
@ -136,7 +132,7 @@ export const RestoreBackup = ({
api.backup.restoreBackupWithLogs.useSubscription(
{
databaseId,
databaseId: id,
databaseType,
databaseName: form.watch("databaseName"),
backupFile: form.watch("backupFile"),

View File

@ -22,41 +22,60 @@ import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup";
import { RestoreBackup } from "./restore-backup";
import { UpdateBackup } from "./update-backup";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
id: string;
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
backupType?: "database" | "compose";
}
export const ShowBackups = ({ id, type }: Props) => {
export const ShowBackups = ({
id,
databaseType,
backupType = "database",
}: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState<
string | undefined
>();
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
};
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type]
? queryMap[type]()
const key = backupType === "database" ? databaseType : "compose";
const query = queryMap[key as keyof typeof queryMap];
const { data: postgres, refetch } = query
? query()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
console.log(postgres);
const mutationMap = {
postgres: () => api.backup.manualBackupPostgres.useMutation(),
mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
compose: () => api.backup.manualBackupCompose.useMutation(),
};
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
type
databaseType
]
? mutationMap[type]()
? mutationMap[databaseType]()
: api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
@ -78,16 +97,17 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
{type !== "web-server" && (
{databaseType !== "web-server" && (
<AddBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div>
@ -110,7 +130,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</span>
</div>
) : (
<div>
<div className="flex flex-col gap-4 w-full">
{postgres?.backups.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<DatabaseBackup className="size-8 text-muted-foreground" />
@ -119,13 +139,14 @@ export const ShowBackups = ({ id, type }: Props) => {
</span>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<AddBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
@ -133,12 +154,41 @@ export const ShowBackups = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col pt-2 gap-4">
<div className="flex flex-col gap-4 w-full">
{backupType === "compose" && (
<AlertBlock type="info">
Deploy is required to apply changes after creating or
updating a backup.
</AlertBlock>
)}
</div>
<div className="flex flex-col gap-6">
{postgres?.backups.map((backup) => (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-8 flex-col gap-8">
{backup.backupType === "compose" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">
Service Name
</span>
<span className="text-sm text-muted-foreground">
{backup.serviceName}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">
Database Type
</span>
<span className="text-sm text-muted-foreground">
{backup.databaseType}
</span>
</div>
</>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground">

View File

@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
@ -31,16 +32,37 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
PenBoxIcon,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
type CacheType = "cache" | "fetch";
const UpdateBackupSchema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
@ -48,6 +70,7 @@ const UpdateBackupSchema = z.object({
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
});
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
@ -59,6 +82,7 @@ interface Props {
export const UpdateBackup = ({ backupId, refetch }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
@ -69,6 +93,24 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
},
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: backup?.composeId || "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled:
isOpen && backup?.backupType === "compose" && !!backup?.composeId,
},
);
const { mutateAsync, isLoading: isLoadingUpdate } =
api.backup.update.useMutation();
@ -80,6 +122,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
},
resolver: zodResolver(UpdateBackupSchema),
});
@ -92,6 +135,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false,
prefix: backup.prefix,
schedule: backup.schedule,
serviceName: backup.serviceName || null,
keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount)
: undefined,
@ -100,6 +144,14 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
}, [form, form.reset, backup]);
const onSubmit = async (data: UpdateBackup) => {
if (backup?.backupType === "compose" && !data.serviceName) {
form.setError("serviceName", {
type: "manual",
message: "Service name is required for compose backups",
});
return;
}
await mutateAsync({
backupId,
destinationId: data.destinationId,
@ -107,6 +159,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
serviceName: data.serviceName,
keepLatestCount: data.keepLatestCount as number | null,
})
.then(async () => {
@ -143,6 +196,11 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="destinationId"
@ -218,6 +276,110 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
</FormItem>
)}
/>
{backup?.backupType === "compose" && (
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<SelectItem value="none" disabled>
Empty
</SelectItem>
)}
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="database"

View File

@ -0,0 +1,5 @@
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@ -617,6 +617,13 @@
"when": 1745723563822,
"tag": "0087_lively_risque",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1745801614194,
"tag": "0088_same_ezekiel",
"breakpoints": true
}
]
}

View File

@ -9,6 +9,7 @@ import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { ProjectLayout } from "@/components/layouts/project-layout";
@ -30,6 +31,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
@ -57,6 +65,8 @@ type TabState =
| "domains"
| "monitoring";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo";
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
@ -65,6 +75,8 @@ const Service = (
const router = useRouter();
const { projectId } = router.query;
const [tab, setTab] = useState<TabState>(activeTab);
const [selectedDatabaseType, setSelectedDatabaseType] =
useState<DatabaseType>("postgres");
useEffect(() => {
if (router.query.tab) {
@ -217,16 +229,17 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-7"
? "lg:grid-cols-8"
: data?.serverId
? "lg:grid-cols-6"
: "lg:grid-cols-7",
? "lg:grid-cols-7"
: "lg:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
@ -245,6 +258,34 @@ const Service = (
<ShowEnvironment id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<div className="flex flex-row items-center gap-2">
<Label>Database Type</Label>
<Select
value={selectedDatabaseType}
onValueChange={(value) =>
setSelectedDatabaseType(value as DatabaseType)
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a database type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="mongo">MongoDB</SelectItem>
</SelectContent>
</Select>
</div>
<ShowBackups
id={composeId}
databaseType={selectedDatabaseType}
backupType="compose"
/>
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">

View File

@ -271,7 +271,7 @@ const Mariadb = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mariadbId} type="mariadb" />
<ShowBackups id={mariadbId} databaseType="mariadb" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -272,7 +272,11 @@ const Mongo = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mongoId} type="mongo" />
<ShowBackups
id={mongoId}
databaseType="mongo"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -252,7 +252,11 @@ const MySql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mysqlId} type="mysql" />
<ShowBackups
id={mysqlId}
databaseType="mysql"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -251,7 +251,11 @@ const Postgresql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={postgresId} type="postgres" />
<ShowBackups
id={postgresId}
databaseType="postgres"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@ -20,7 +20,11 @@ const Page = () => {
<WebServer />
<div className="w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<ShowBackups id={user?.userId ?? ""} type="web-server" />
<ShowBackups
id={user?.userId ?? ""}
databaseType="web-server"
backupType="database"
/>
</Card>
</div>
</div>

View File

@ -82,6 +82,11 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
) {
serverId = backup.compose.serverId;
}
const server = await findServerById(serverId);
@ -232,6 +237,13 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupCompose: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
// const backup = await findBackupById(input.backupId);
// await runComposeBackup(backup);
return true;
}),
manualBackupMongo: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {

View File

@ -723,4 +723,18 @@ export const composeRouter = createTRPCRouter({
});
}
}),
manualBackup: protectedProcedure
.input(z.object({ composeId: z.string() }))
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to backup this compose",
});
}
await createBackup({
composeId: compose.composeId,
});
}),
});

View File

@ -16,6 +16,8 @@ import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { compose } from "./compose";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@ -24,6 +26,8 @@ export const databaseType = pgEnum("databaseType", [
"web-server",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
export const backups = pgTable("backup", {
backupId: text("backupId")
.notNull()
@ -33,14 +37,19 @@ export const backups = pgTable("backup", {
enabled: boolean("enabled"),
database: text("database").notNull(),
prefix: text("prefix").notNull(),
serviceName: text("serviceName"),
destinationId: text("destinationId")
.notNull()
.references(() => destinations.destinationId, { onDelete: "cascade" }),
keepLatestCount: integer("keepLatestCount"),
backupType: backupType("backupType").notNull().default("database"),
databaseType: databaseType("databaseType").notNull(),
composeId: text("composeId").references(
(): AnyPgColumn => compose.composeId,
{
onDelete: "cascade",
},
),
postgresId: text("postgresId").references(
(): AnyPgColumn => postgres.postgresId,
{
@ -87,6 +96,10 @@ export const backupsRelations = relations(backups, ({ one }) => ({
fields: [backups.userId],
references: [users_temp.id],
}),
compose: one(compose, {
fields: [backups.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(backups, {
@ -118,6 +131,9 @@ export const apiCreateBackup = createSchema.pick({
mongoId: true,
databaseType: true,
userId: true,
backupType: true,
composeId: true,
serviceName: true,
});
export const apiFindOneBackup = createSchema
@ -141,5 +157,6 @@ export const apiUpdateBackup = createSchema
destinationId: true,
database: true,
keepLatestCount: true,
serviceName: true,
})
.required();

View File

@ -15,6 +15,7 @@ import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { backups } from "./backups";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
@ -135,6 +136,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
fields: [compose.serverId],
references: [server.serverId],
}),
backups: many(backups),
}));
const createSchema = createInsertSchema(compose, {

View File

@ -35,6 +35,7 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
destination: true,
compose: true,
},
});
if (!backup) {

View File

@ -131,6 +131,11 @@ export const findComposeById = async (composeId: string) => {
bitbucket: true,
gitea: true,
server: true,
backups: {
with: {
destination: true,
},
},
},
});
if (!result) {