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 { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -31,42 +32,59 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
type CacheType = "cache" | "fetch";
const AddPostgresBackup1Schema = z.object({ const AddPostgresBackup1Schema = z.object({
destinationId: z.string().min(1, "Destination required"), destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) 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"), prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(), enabled: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
}); });
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>; type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props { interface Props {
databaseId: string; id: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void; 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 { data, isLoading } = api.destination.all.useQuery();
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } = const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
api.backup.create.useMutation(); api.backup.create.useMutation();
@@ -79,10 +97,28 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
serviceName: null,
}, },
resolver: zodResolver(AddPostgresBackup1Schema), 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(() => { useEffect(() => {
form.reset({ form.reset({
database: databaseType === "web-server" ? "dokploy" : "", database: databaseType === "web-server" ? "dokploy" : "",
@@ -91,32 +127,45 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
serviceName: null,
}); });
}, [form, form.reset, form.formState.isSubmitSuccessful]); }, [form, form.reset, form.formState.isSubmitSuccessful, databaseType]);
const onSubmit = async (data: AddPostgresBackup) => { 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 = 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({ await createBackup({
destinationId: data.destinationId, destinationId: data.destinationId,
@@ -125,8 +174,10 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: data.enabled, enabled: data.enabled,
database: data.database, database: data.database,
keepLatestCount: data.keepLatestCount, keepLatestCount: data.keepLatestCount,
databaseType, databaseType: databaseType,
serviceName: data.serviceName,
...getDatabaseId, ...getDatabaseId,
backupType,
}) })
.then(async () => { .then(async () => {
toast.success("Backup Created"); toast.success("Backup Created");
@@ -157,6 +208,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
className="grid w-full gap-4" className="grid w-full gap-4"
> >
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
<FormField <FormField
control={form.control} control={form.control}
name="destinationId" name="destinationId"
@@ -232,6 +288,110 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="database" name="database"

View File

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

View File

@@ -22,41 +22,60 @@ import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup"; import { AddBackup } from "./add-backup";
import { RestoreBackup } from "./restore-backup"; import { RestoreBackup } from "./restore-backup";
import { UpdateBackup } from "./update-backup"; import { UpdateBackup } from "./update-backup";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props { interface Props {
id: string; 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< const [activeManualBackup, setActiveManualBackup] = useState<
string | undefined string | undefined
>(); >();
const queryMap = { const queryMap =
postgres: () => backupType === "database"
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), ? {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () =>
mariadb: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mysql: () =>
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(), 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 } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type] const key = backupType === "database" ? databaseType : "compose";
? queryMap[type]() const query = queryMap[key as keyof typeof queryMap];
const { data: postgres, refetch } = query
? query()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
console.log(postgres);
const mutationMap = { const mutationMap = {
postgres: () => api.backup.manualBackupPostgres.useMutation(), postgres: () => api.backup.manualBackupPostgres.useMutation(),
mysql: () => api.backup.manualBackupMySql.useMutation(), mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(), mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(), mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(), "web-server": () => api.backup.manualBackupWebServer.useMutation(),
compose: () => api.backup.manualBackupCompose.useMutation(),
}; };
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[ const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
type databaseType
] ]
? mutationMap[type]() ? mutationMap[databaseType]()
: api.backup.manualBackupMongo.useMutation(); : api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } = const { mutateAsync: deleteBackup, isLoading: isRemoving } =
@@ -78,16 +97,17 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && ( {postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto"> <div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
{type !== "web-server" && ( {databaseType !== "web-server" && (
<AddBackup <AddBackup
databaseId={id} id={id}
databaseType={type} databaseType={databaseType}
backupType={backupType}
refetch={refetch} refetch={refetch}
/> />
)} )}
<RestoreBackup <RestoreBackup
databaseId={id} id={id}
databaseType={type} databaseType={databaseType}
serverId={"serverId" in postgres ? postgres.serverId : undefined} serverId={"serverId" in postgres ? postgres.serverId : undefined}
/> />
</div> </div>
@@ -110,7 +130,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</span> </span>
</div> </div>
) : ( ) : (
<div> <div className="flex flex-col gap-4 w-full">
{postgres?.backups.length === 0 ? ( {postgres?.backups.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10"> <div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<DatabaseBackup className="size-8 text-muted-foreground" /> <DatabaseBackup className="size-8 text-muted-foreground" />
@@ -119,13 +139,14 @@ export const ShowBackups = ({ id, type }: Props) => {
</span> </span>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<AddBackup <AddBackup
databaseId={id} id={id}
databaseType={type} databaseType={databaseType}
backupType={backupType}
refetch={refetch} refetch={refetch}
/> />
<RestoreBackup <RestoreBackup
databaseId={id} id={id}
databaseType={type} databaseType={databaseType}
serverId={ serverId={
"serverId" in postgres ? postgres.serverId : undefined "serverId" in postgres ? postgres.serverId : undefined
} }
@@ -133,12 +154,41 @@ export const ShowBackups = ({ id, type }: Props) => {
</div> </div>
</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"> <div className="flex flex-col gap-6">
{postgres?.backups.map((backup) => ( {postgres?.backups.map((backup) => (
<div key={backup.backupId}> <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="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"> <div className="flex flex-col gap-1">
<span className="font-medium">Destination</span> <span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground"> <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 { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -31,16 +32,37 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
type CacheType = "cache" | "fetch";
const UpdateBackupSchema = z.object({ const UpdateBackupSchema = z.object({
destinationId: z.string().min(1, "Destination required"), destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"), schedule: z.string().min(1, "Schedule (Cron) required"),
@@ -48,6 +70,7 @@ const UpdateBackupSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
}); });
type UpdateBackup = z.infer<typeof UpdateBackupSchema>; type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
@@ -59,6 +82,7 @@ interface Props {
export const UpdateBackup = ({ backupId, refetch }: Props) => { export const UpdateBackup = ({ backupId, refetch }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { data, isLoading } = api.destination.all.useQuery(); const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.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 } = const { mutateAsync, isLoading: isLoadingUpdate } =
api.backup.update.useMutation(); api.backup.update.useMutation();
@@ -80,6 +122,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined, keepLatestCount: undefined,
serviceName: null,
}, },
resolver: zodResolver(UpdateBackupSchema), resolver: zodResolver(UpdateBackupSchema),
}); });
@@ -92,6 +135,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false, enabled: backup.enabled || false,
prefix: backup.prefix, prefix: backup.prefix,
schedule: backup.schedule, schedule: backup.schedule,
serviceName: backup.serviceName || null,
keepLatestCount: backup.keepLatestCount keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount) ? Number(backup.keepLatestCount)
: undefined, : undefined,
@@ -100,6 +144,14 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
}, [form, form.reset, backup]); }, [form, form.reset, backup]);
const onSubmit = async (data: UpdateBackup) => { 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({ await mutateAsync({
backupId, backupId,
destinationId: data.destinationId, destinationId: data.destinationId,
@@ -107,6 +159,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
schedule: data.schedule, schedule: data.schedule,
enabled: data.enabled, enabled: data.enabled,
database: data.database, database: data.database,
serviceName: data.serviceName,
keepLatestCount: data.keepLatestCount as number | null, keepLatestCount: data.keepLatestCount as number | null,
}) })
.then(async () => { .then(async () => {
@@ -143,6 +196,11 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
className="grid w-full gap-4" className="grid w-full gap-4"
> >
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
<FormField <FormField
control={form.control} control={form.control}
name="destinationId" name="destinationId"
@@ -218,6 +276,110 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="database" 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, "when": 1745723563822,
"tag": "0087_lively_risque", "tag": "0087_lively_risque",
"breakpoints": true "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 { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack"; import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose"; 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 { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring"; import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { ProjectLayout } from "@/components/layouts/project-layout"; import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -30,6 +31,13 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -57,6 +65,8 @@ type TabState =
| "domains" | "domains"
| "monitoring"; | "monitoring";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo";
const Service = ( const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>, props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => { ) => {
@@ -65,6 +75,8 @@ const Service = (
const router = useRouter(); const router = useRouter();
const { projectId } = router.query; const { projectId } = router.query;
const [tab, setTab] = useState<TabState>(activeTab); const [tab, setTab] = useState<TabState>(activeTab);
const [selectedDatabaseType, setSelectedDatabaseType] =
useState<DatabaseType>("postgres");
useEffect(() => { useEffect(() => {
if (router.query.tab) { if (router.query.tab) {
@@ -217,16 +229,17 @@ const Service = (
className={cn( className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start", "lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId isCloud && data?.serverId
? "lg:grid-cols-7" ? "lg:grid-cols-8"
: data?.serverId : 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="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger> <TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger> <TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
@@ -245,6 +258,34 @@ const Service = (
<ShowEnvironment id={composeId} type="compose" /> <ShowEnvironment id={composeId} type="compose" />
</div> </div>
</TabsContent> </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"> <TabsContent value="monitoring">
<div className="pt-2.5"> <div className="pt-2.5">

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,11 @@ const Page = () => {
<WebServer /> <WebServer />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full"> <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> </Card>
</div> </div>
</div> </div>

View File

@@ -82,6 +82,11 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId; serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) { } else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId; serverId = backup.mariadb.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
) {
serverId = backup.compose.serverId;
} }
const server = await findServerById(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 manualBackupMongo: protectedProcedure
.input(apiFindOneBackup) .input(apiFindOneBackup)
.mutation(async ({ input }) => { .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 { mysql } from "./mysql";
import { postgres } from "./postgres"; import { postgres } from "./postgres";
import { users_temp } from "./user"; import { users_temp } from "./user";
import { compose } from "./compose";
export const databaseType = pgEnum("databaseType", [ export const databaseType = pgEnum("databaseType", [
"postgres", "postgres",
"mariadb", "mariadb",
@@ -24,6 +26,8 @@ export const databaseType = pgEnum("databaseType", [
"web-server", "web-server",
]); ]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
export const backups = pgTable("backup", { export const backups = pgTable("backup", {
backupId: text("backupId") backupId: text("backupId")
.notNull() .notNull()
@@ -33,14 +37,19 @@ export const backups = pgTable("backup", {
enabled: boolean("enabled"), enabled: boolean("enabled"),
database: text("database").notNull(), database: text("database").notNull(),
prefix: text("prefix").notNull(), prefix: text("prefix").notNull(),
serviceName: text("serviceName"),
destinationId: text("destinationId") destinationId: text("destinationId")
.notNull() .notNull()
.references(() => destinations.destinationId, { onDelete: "cascade" }), .references(() => destinations.destinationId, { onDelete: "cascade" }),
keepLatestCount: integer("keepLatestCount"), keepLatestCount: integer("keepLatestCount"),
backupType: backupType("backupType").notNull().default("database"),
databaseType: databaseType("databaseType").notNull(), databaseType: databaseType("databaseType").notNull(),
composeId: text("composeId").references(
(): AnyPgColumn => compose.composeId,
{
onDelete: "cascade",
},
),
postgresId: text("postgresId").references( postgresId: text("postgresId").references(
(): AnyPgColumn => postgres.postgresId, (): AnyPgColumn => postgres.postgresId,
{ {
@@ -87,6 +96,10 @@ export const backupsRelations = relations(backups, ({ one }) => ({
fields: [backups.userId], fields: [backups.userId],
references: [users_temp.id], references: [users_temp.id],
}), }),
compose: one(compose, {
fields: [backups.composeId],
references: [compose.composeId],
}),
})); }));
const createSchema = createInsertSchema(backups, { const createSchema = createInsertSchema(backups, {
@@ -118,6 +131,9 @@ export const apiCreateBackup = createSchema.pick({
mongoId: true, mongoId: true,
databaseType: true, databaseType: true,
userId: true, userId: true,
backupType: true,
composeId: true,
serviceName: true,
}); });
export const apiFindOneBackup = createSchema export const apiFindOneBackup = createSchema
@@ -141,5 +157,6 @@ export const apiUpdateBackup = createSchema
destinationId: true, destinationId: true,
database: true, database: true,
keepLatestCount: true, keepLatestCount: true,
serviceName: true,
}) })
.required(); .required();

View File

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

View File

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

View File

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