mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-30 03:25:22 +02:00
feat: add bottomless replication
This commit is contained in:
@@ -39,7 +39,9 @@ import { RestoreBackup } from "./restore-backup";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||
databaseType?:
|
||||
| Exclude<ServiceType, "application" | "redis" | "libsql">
|
||||
| "web-server";
|
||||
backupType?: "database" | "compose";
|
||||
}
|
||||
export const ShowBackups = ({
|
||||
@@ -53,14 +55,14 @@ export const ShowBackups = ({
|
||||
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 }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
"web-server": () => api.user.getBackups.useQuery(),
|
||||
}
|
||||
: {
|
||||
@@ -77,10 +79,10 @@ export const ShowBackups = ({
|
||||
const mutationMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
enableBottomlessReplication: boolean;
|
||||
bottomlessReplicationDestinationId?: string | null;
|
||||
}
|
||||
|
||||
export const ShowBottomlessReplication = ({
|
||||
libsqlId,
|
||||
enableBottomlessReplication,
|
||||
bottomlessReplicationDestinationId,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const switchId = useId();
|
||||
const commandId = useId();
|
||||
const { mutateAsync, isLoading } = api.libsql.update.useMutation();
|
||||
const { data: destinations, isLoading: isLoadingDestinations } =
|
||||
api.destination.all.useQuery();
|
||||
const [isDestinationOpen, setIsDestinationOpen] = useState(false);
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
libsqlId,
|
||||
enableBottomlessReplication: checked,
|
||||
});
|
||||
toast.success("Bottomless replication updated successfully");
|
||||
utils.libsql.one.invalidate({ libsqlId });
|
||||
} catch (error) {
|
||||
toast.error("Error updating bottomless replication");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDestinationSelect = async (destinationId: string | null) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
libsqlId,
|
||||
enableBottomlessReplication:
|
||||
destinationId === null ? false : enableBottomlessReplication,
|
||||
bottomlessReplicationDestinationId: destinationId,
|
||||
});
|
||||
toast.success("Bottomless replication destination updated successfully");
|
||||
utils.libsql.one.invalidate({ libsqlId });
|
||||
setIsDestinationOpen(false);
|
||||
} catch (error) {
|
||||
toast.error("Error updating bottomless replication destination");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Bottomless Replication</CardTitle>
|
||||
<CardDescription>
|
||||
Bottomless replication allows automatically backing up your database
|
||||
to an S3-compatible storage.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
The service needs to be restarted for bottomless replication changes
|
||||
to take effect. Please redeploy the service after enabling or
|
||||
disabling this feature.
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={switchId} className="text-sm font-medium">
|
||||
Enable Bottomless Replication
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically replicate database changes to S3-compatible storage
|
||||
</p>
|
||||
{!bottomlessReplicationDestinationId && (
|
||||
<p className="text-sm text-orange-600">
|
||||
Select a destination above to enable bottomless replication
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enableBottomlessReplication}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isLoading || !bottomlessReplicationDestinationId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={commandId} className="text-sm font-medium">
|
||||
Destination
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the S3-compatible destination for bottomless replication
|
||||
</p>
|
||||
<Popover open={isDestinationOpen} onOpenChange={setIsDestinationOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!bottomlessReplicationDestinationId &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingDestinations
|
||||
? "Loading...."
|
||||
: bottomlessReplicationDestinationId
|
||||
? destinations?.find(
|
||||
(destination) =>
|
||||
destination.destinationId ===
|
||||
bottomlessReplicationDestinationId,
|
||||
)?.name
|
||||
: "Select Destination"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command id={commandId}>
|
||||
<CommandInput
|
||||
placeholder="Search Destination..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingDestinations && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Destinations....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{destinations?.map((destination) => (
|
||||
<CommandItem
|
||||
value={destination.destinationId}
|
||||
key={destination.destinationId}
|
||||
onSelect={() =>
|
||||
handleDestinationSelect(destination.destinationId)
|
||||
}
|
||||
>
|
||||
{destination.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
destination.destinationId ===
|
||||
bottomlessReplicationDestinationId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => handleDestinationSelect(null)}
|
||||
>
|
||||
None
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
!bottomlessReplicationDestinationId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -144,7 +144,7 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
return `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
@@ -153,7 +153,7 @@ export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
if (data?.sqldNode === "replica") return "";
|
||||
const port = form.watch("externalGRPCPort") || data?.externalGRPCPort;
|
||||
|
||||
return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
return `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setGRPCConnectionUrl(buildGRPCConnectionUrl());
|
||||
|
||||
3
apps/dokploy/drizzle/0112_mean_cammi.sql
Normal file
3
apps/dokploy/drizzle/0112_mean_cammi.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "libsql" ADD COLUMN "enableBottomlessReplication" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD COLUMN "bottomlessReplicationDestinationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD CONSTRAINT "libsql_bottomlessReplicationDestinationId_destination_destinationId_fk" FOREIGN KEY ("bottomlessReplicationDestinationId") REFERENCES "public"."destination"("destinationId") ON DELETE set null ON UPDATE no action;
|
||||
6878
apps/dokploy/drizzle/meta/0112_snapshot.json
Normal file
6878
apps/dokploy/drizzle/meta/0112_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -785,6 +785,13 @@
|
||||
"when": 1757764040899,
|
||||
"tag": "0111_opposite_outlaw_kid",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 112,
|
||||
"version": "7",
|
||||
"when": 1757841949939,
|
||||
"tag": "0112_mean_cammi",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import superjson from "superjson";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowBottomlessReplication } from "@/components/dashboard/libsql/general/show-bottomless-replication";
|
||||
import { ShowExternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-external-libsql-credentials";
|
||||
import { ShowGeneralLibsql } from "@/components/dashboard/libsql/general/show-general-libsql";
|
||||
import { ShowInternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-internal-libsql-credentials";
|
||||
@@ -273,6 +274,19 @@ const Libsql = (
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="backups">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowBottomlessReplication
|
||||
libsqlId={libsqlId}
|
||||
enableBottomlessReplication={
|
||||
data?.enableBottomlessReplication || false
|
||||
}
|
||||
bottomlessReplicationDestinationId={
|
||||
data?.bottomlessReplicationDestinationId
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowDatabaseAdvancedSettings
|
||||
|
||||
@@ -3,6 +3,8 @@ import { boolean, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { backups } from "./backups";
|
||||
import { destinations } from "./destination";
|
||||
import { environments } from "./environment";
|
||||
import { mounts } from "./mount";
|
||||
import { server } from "./server";
|
||||
@@ -42,6 +44,14 @@ export const libsql = pgTable("libsql", {
|
||||
sqldNode: sqldNode("sqldNode").notNull().default("primary"),
|
||||
sqldPrimaryUrl: text("sqldPrimaryUrl"),
|
||||
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
|
||||
enableBottomlessReplication: boolean("enableBottomlessReplication")
|
||||
.notNull()
|
||||
.default(false),
|
||||
bottomlessReplicationDestinationId: text(
|
||||
"bottomlessReplicationDestinationId",
|
||||
).references(() => destinations.destinationId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
env: text("env"),
|
||||
@@ -89,6 +99,10 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
|
||||
fields: [libsql.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
bottomlessReplicationDestination: one(destinations, {
|
||||
fields: [libsql.bottomlessReplicationDestinationId],
|
||||
references: [destinations.destinationId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(libsql, {
|
||||
@@ -106,6 +120,8 @@ const createSchema = createInsertSchema(libsql, {
|
||||
sqldNode: z.enum(sqldNode.enumValues),
|
||||
sqldPrimaryUrl: z.string().nullable(),
|
||||
enableNamespaces: z.boolean().default(false),
|
||||
enableBottomlessReplication: z.boolean().default(false),
|
||||
bottomlessReplicationDestinationId: z.string().nullable(),
|
||||
dockerImage: z.string().default("ghcr.io/tursodatabase/libsql-server:latest"),
|
||||
command: z.string().optional(),
|
||||
env: z.string().optional(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export const findLibsqlById = async (libsqlId: string) => {
|
||||
},
|
||||
mounts: true,
|
||||
server: true,
|
||||
bottomlessReplicationDestination: true,
|
||||
// backups: {
|
||||
// with: {
|
||||
// destination: true,
|
||||
|
||||
@@ -31,7 +31,11 @@ const getLibsqlImage = (arch: string): string => {
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
{
|
||||
mounts: true;
|
||||
environment: { with: { project: true } };
|
||||
bottomlessReplicationDestination: true;
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
const {
|
||||
@@ -53,6 +57,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
mounts,
|
||||
serverId,
|
||||
enableNamespaces,
|
||||
enableBottomlessReplication,
|
||||
bottomlessReplicationDestination,
|
||||
} = libsql;
|
||||
|
||||
let finalDockerImage = dockerImage;
|
||||
@@ -66,10 +72,20 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
"utf-8",
|
||||
).toString("base64");
|
||||
|
||||
const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
|
||||
let defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
|
||||
env ? `\n${env}` : ""
|
||||
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
|
||||
|
||||
// Add bottomless replication environment variables if destination is configured
|
||||
if (enableBottomlessReplication && bottomlessReplicationDestination) {
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_DATABASE_ID="${appName}"`;
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_BUCKET="${bottomlessReplicationDestination.bucket}"`;
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_ENDPOINT="${bottomlessReplicationDestination.endpoint}"`;
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_SECRET_ACCESS_KEY="${bottomlessReplicationDestination.secretAccessKey}"`;
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_ACCESS_KEY_ID="${bottomlessReplicationDestination.accessKey}"`;
|
||||
defaultLibsqlEnv += `\nLIBSQL_BOTTOMLESS_AWS_DEFAULT_REGION="${bottomlessReplicationDestination.region}"`;
|
||||
}
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -103,6 +119,9 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
if (enableNamespaces) {
|
||||
finalCommand += " --enable-namespaces";
|
||||
}
|
||||
if (enableBottomlessReplication) {
|
||||
finalCommand += " --enable-bottomless-replication";
|
||||
}
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
Name: appName,
|
||||
|
||||
Reference in New Issue
Block a user