mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
refactor: agroupate utilities
This commit is contained in:
@@ -15,7 +15,8 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
|
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
|
||||||
import { RandomizeCompose } from "./randomize-compose";
|
import { RandomizeCompose } from "./randomize-compose";
|
||||||
import { RandomizeDeployable } from "./randomize-deployable";
|
import { RandomizeDeployable } from "./isolated-deployment";
|
||||||
|
import { ShowUtilities } from "./show-utilities";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
@@ -126,8 +127,7 @@ services:
|
|||||||
</Form>
|
</Form>
|
||||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
|
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
|
||||||
<RandomizeCompose composeId={composeId} />
|
<ShowUtilities composeId={composeId} />
|
||||||
<RandomizeDeployable composeId={composeId} />
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
isolatedDeployment: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [compose, setCompose] = useState<string>("");
|
||||||
|
const { mutateAsync, error, isError } =
|
||||||
|
api.compose.isolatedDeployment.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
|
||||||
|
|
||||||
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
|
{ composeId },
|
||||||
|
{ enabled: !!composeId },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
isolatedDeployment: false,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
randomizeCompose();
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
isolatedDeployment: data?.isolatedDeployment || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Schema) => {
|
||||||
|
await updateCompose({
|
||||||
|
composeId,
|
||||||
|
isolatedDeployment: formData?.isolatedDeployment || false,
|
||||||
|
})
|
||||||
|
.then(async (data) => {
|
||||||
|
randomizeCompose();
|
||||||
|
refetch();
|
||||||
|
toast.success("Compose updated");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating the compose");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomizeCompose = async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
composeId,
|
||||||
|
suffix: data?.appName || "",
|
||||||
|
})
|
||||||
|
.then(async (data) => {
|
||||||
|
await utils.project.all.invalidate();
|
||||||
|
setCompose(data);
|
||||||
|
toast.success("Compose Isolated");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error isolating the compose");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Isolate Deployment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Use this option to isolate the deployment of this compose file.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
This feature creates an isolated environment for your deployment by
|
||||||
|
adding unique prefixes to all resources. It establishes a dedicated
|
||||||
|
network based on your compose file's name, ensuring your services run
|
||||||
|
in isolation. This prevents conflicts when running multiple instances
|
||||||
|
of the same template or services with identical names.
|
||||||
|
</span>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">
|
||||||
|
Resources that will be isolated:
|
||||||
|
</h4>
|
||||||
|
<ul className="list-disc list-inside">
|
||||||
|
<li>Docker volumes</li>
|
||||||
|
<li>Docker networks</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="hook-form-add-project"
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
{isError && (
|
||||||
|
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
|
||||||
|
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error?.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-col gap-4 w-full ">
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isolatedDeployment"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable randomize to the compose file.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
|
||||||
|
<Button
|
||||||
|
form="hook-form-add-project"
|
||||||
|
type="submit"
|
||||||
|
className="lg:w-fit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Label>Preview</Label>
|
||||||
|
<pre>
|
||||||
|
<CodeEditor
|
||||||
|
value={compose || ""}
|
||||||
|
language="yaml"
|
||||||
|
readOnly
|
||||||
|
height="50rem"
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -20,11 +16,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot,
|
|
||||||
} from "@/components/ui/input-otp";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -70,6 +61,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
const suffix = form.watch("suffix");
|
const suffix = form.watch("suffix");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
randomizeCompose();
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
suffix: data?.suffix || "",
|
suffix: data?.suffix || "",
|
||||||
@@ -110,19 +102,12 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<div className="w-full">
|
||||||
<DialogTrigger asChild onClick={() => randomizeCompose()}>
|
|
||||||
<Button className="max-lg:w-full" variant="outline">
|
|
||||||
<Dices className="h-4 w-4" />
|
|
||||||
Randomize Compose
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
|
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Use this in case you want to deploy the same compose file and you
|
Use this in case you want to deploy the same compose file and you have
|
||||||
have conflicts with some property like volumes, networks, etc.
|
conflicts with some property like volumes, networks, etc.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||||
@@ -138,9 +123,8 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
<li>secrets</li>
|
<li>secrets</li>
|
||||||
</ul>
|
</ul>
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
When you activate this option, we will include a env
|
When you activate this option, we will include a env `COMPOSE_PREFIX`
|
||||||
`COMPOSE_PREFIX` variable to the compose file so you can use it in
|
variable to the compose file so you can use it in your compose file.
|
||||||
your compose file.
|
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
</div>
|
</div>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
@@ -165,7 +149,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="suffix"
|
name="suffix"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col justify-center max-sm:items-center w-full">
|
<FormItem className="flex flex-col justify-center max-sm:items-center w-full mt-4">
|
||||||
<FormLabel>Suffix</FormLabel>
|
<FormLabel>Suffix</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -229,7 +213,6 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
</pre>
|
</pre>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { AlertTriangle, Dices } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
deployable: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
export const RandomizeDeployable = ({ composeId }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const [compose, setCompose] = useState<string>("");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { mutateAsync, error, isError } =
|
|
||||||
api.compose.randomizeDeployableCompose.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
|
|
||||||
|
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
|
||||||
{ composeId },
|
|
||||||
{ enabled: !!composeId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
|
||||||
defaultValues: {
|
|
||||||
deployable: false,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
deployable: data?.deployable || false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: Schema) => {
|
|
||||||
await updateCompose({
|
|
||||||
composeId,
|
|
||||||
deployable: formData?.deployable || false,
|
|
||||||
})
|
|
||||||
.then(async (data) => {
|
|
||||||
randomizeCompose();
|
|
||||||
refetch();
|
|
||||||
toast.success("Compose updated");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error randomizing the compose");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomizeCompose = async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
composeId,
|
|
||||||
suffix: data?.appName || "",
|
|
||||||
})
|
|
||||||
.then(async (data) => {
|
|
||||||
await utils.project.all.invalidate();
|
|
||||||
setCompose(data);
|
|
||||||
toast.success("Compose randomized");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error randomizing the compose");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild onClick={() => randomizeCompose()}>
|
|
||||||
<Button className="max-lg:w-full" variant="outline">
|
|
||||||
<Dices className="h-4 w-4" />
|
|
||||||
Randomize Deployable
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Randomize Deployable (Experimental)</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Use this in case you want to deploy the same compose file twice.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
|
||||||
<span>
|
|
||||||
This will randomize the compose file and will add a suffix to the
|
|
||||||
property to avoid conflicts
|
|
||||||
</span>
|
|
||||||
<ul className="list-disc list-inside">
|
|
||||||
<li>volumes</li>
|
|
||||||
<li>networks</li>
|
|
||||||
</ul>
|
|
||||||
<AlertBlock type="info">
|
|
||||||
When you activate this option, we will include a env
|
|
||||||
`DOKPLOY_SUFFIX` variable to the compose file so you can use it in
|
|
||||||
your compose file, also we don't include the{" "}
|
|
||||||
<code>dokploy-network</code> to any of the services by default.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
id="hook-form-add-project"
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
{isError && (
|
|
||||||
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
|
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error?.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-col gap-4 w-full ">
|
|
||||||
<div>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="deployable"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Apply Randomize ({data?.appName})</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Apply randomize to the compose file.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
|
|
||||||
<Button
|
|
||||||
form="hook-form-add-project"
|
|
||||||
type="submit"
|
|
||||||
className="lg:w-fit"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre>
|
|
||||||
<CodeEditor
|
|
||||||
value={compose || ""}
|
|
||||||
language="yaml"
|
|
||||||
readOnly
|
|
||||||
height="50rem"
|
|
||||||
/>
|
|
||||||
</pre>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { IsolatedDeployment } from "./isolated-deployment";
|
||||||
|
import { RandomizeCompose } from "./randomize-compose";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowUtilities = ({ composeId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost">Show Utilities</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Utilities </DialogTitle>
|
||||||
|
<DialogDescription>Modify the application data</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="isolated">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="isolated">Isolated Deployment</TabsTrigger>
|
||||||
|
<TabsTrigger value="randomize">Randomize Compose</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="randomize" className="pt-5">
|
||||||
|
<RandomizeCompose composeId={composeId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="isolated" className="pt-5">
|
||||||
|
<IsolatedDeployment composeId={composeId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
apps/dokploy/drizzle/0065_daily_zaladane.sql
Normal file
1
apps/dokploy/drizzle/0065_daily_zaladane.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "compose" RENAME COLUMN "deployable" TO "isolatedDeployment";
|
||||||
4485
apps/dokploy/drizzle/meta/0065_snapshot.json
Normal file
4485
apps/dokploy/drizzle/meta/0065_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -456,6 +456,13 @@
|
|||||||
"when": 1738564387043,
|
"when": 1738564387043,
|
||||||
"tag": "0064_previous_agent_brand",
|
"tag": "0064_previous_agent_brand",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 65,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1739087857244,
|
||||||
|
"tag": "0065_daily_zaladane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
findServerById,
|
findServerById,
|
||||||
loadServices,
|
loadServices,
|
||||||
randomizeComposeFile,
|
randomizeComposeFile,
|
||||||
randomizeDeployableComposeFile,
|
randomizeIsolatedDeploymentComposeFile,
|
||||||
removeCompose,
|
removeCompose,
|
||||||
removeComposeDirectory,
|
removeComposeDirectory,
|
||||||
removeDeploymentsByComposeId,
|
removeDeploymentsByComposeId,
|
||||||
@@ -217,7 +217,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await randomizeComposeFile(input.composeId, input.suffix);
|
return await randomizeComposeFile(input.composeId, input.suffix);
|
||||||
}),
|
}),
|
||||||
randomizeDeployableCompose: protectedProcedure
|
isolatedDeployment: protectedProcedure
|
||||||
.input(apiRandomizeCompose)
|
.input(apiRandomizeCompose)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const compose = await findComposeById(input.composeId);
|
const compose = await findComposeById(input.composeId);
|
||||||
@@ -227,9 +227,9 @@ export const composeRouter = createTRPCRouter({
|
|||||||
message: "You are not authorized to randomize this compose",
|
message: "You are not authorized to randomize this compose",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return await randomizeDeployableComposeFile(
|
return await randomizeIsolatedDeploymentComposeFile(
|
||||||
input.composeId,
|
input.composeId,
|
||||||
input.suffix,
|
input.suffix
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
getConvertedCompose: protectedProcedure
|
getConvertedCompose: protectedProcedure
|
||||||
@@ -280,7 +280,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
{
|
{
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
redeploy: protectedProcedure
|
redeploy: protectedProcedure
|
||||||
@@ -312,7 +312,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
{
|
{
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
stop: protectedProcedure
|
stop: protectedProcedure
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ export const compose = pgTable("compose", {
|
|||||||
() => sshKeys.sshKeyId,
|
() => sshKeys.sshKeyId,
|
||||||
{
|
{
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
},
|
}
|
||||||
),
|
),
|
||||||
command: text("command").notNull().default(""),
|
command: text("command").notNull().default(""),
|
||||||
//
|
//
|
||||||
composePath: text("composePath").notNull().default("./docker-compose.yml"),
|
composePath: text("composePath").notNull().default("./docker-compose.yml"),
|
||||||
suffix: text("suffix").notNull().default(""),
|
suffix: text("suffix").notNull().default(""),
|
||||||
randomize: boolean("randomize").notNull().default(false),
|
randomize: boolean("randomize").notNull().default(false),
|
||||||
deployable: boolean("deployable").notNull().default(false),
|
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
||||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
|
|||||||
await writeDomainsToCompose(compose, domains);
|
await writeDomainsToCompose(compose, domains);
|
||||||
createEnvFile(compose);
|
createEnvFile(compose);
|
||||||
|
|
||||||
if (compose.deployable) {
|
if (compose.isolatedDeployment) {
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}`,
|
`docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +76,12 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
|
|||||||
...getEnviromentVariablesObject(compose.env, compose.project.env),
|
...getEnviromentVariablesObject(compose.env, compose.project.env),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (compose.deployable) {
|
if (compose.isolatedDeployment) {
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1`,
|
`docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
|
|||||||
|
|
||||||
export const getBuildComposeCommand = async (
|
export const getBuildComposeCommand = async (
|
||||||
compose: ComposeNested,
|
compose: ComposeNested,
|
||||||
logPath: string,
|
logPath: string
|
||||||
) => {
|
) => {
|
||||||
const { COMPOSE_PATH } = paths(true);
|
const { COMPOSE_PATH } = paths(true);
|
||||||
const { sourceType, appName, mounts, composeType, domains, composePath } =
|
const { sourceType, appName, mounts, composeType, domains, composePath } =
|
||||||
@@ -109,7 +109,7 @@ export const getBuildComposeCommand = async (
|
|||||||
const newCompose = await writeDomainsToComposeRemote(
|
const newCompose = await writeDomainsToComposeRemote(
|
||||||
compose,
|
compose,
|
||||||
domains,
|
domains,
|
||||||
logPath,
|
logPath
|
||||||
);
|
);
|
||||||
const logContent = `
|
const logContent = `
|
||||||
App Name: ${appName}
|
App Name: ${appName}
|
||||||
@@ -141,9 +141,9 @@ Compose Type: ${composeType} ✅`;
|
|||||||
cd "${projectPath}";
|
cd "${projectPath}";
|
||||||
|
|
||||||
${exportEnvCommand}
|
${exportEnvCommand}
|
||||||
${compose.deployable ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
||||||
docker ${command.split(" ").join(" ")} >> "${logPath}" 2>&1 || { echo "Error: ❌ Docker command failed" >> "${logPath}"; exit 1; }
|
docker ${command.split(" ").join(" ")} >> "${logPath}" 2>&1 || { echo "Error: ❌ Docker command failed" >> "${logPath}"; exit 1; }
|
||||||
${compose.deployable ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||||
|
|
||||||
echo "Docker Compose Deployed: ✅" >> "${logPath}"
|
echo "Docker Compose Deployed: ✅" >> "${logPath}"
|
||||||
} || {
|
} || {
|
||||||
@@ -203,7 +203,7 @@ const createEnvFile = (compose: ComposeNested) => {
|
|||||||
|
|
||||||
const envFileContent = prepareEnvironmentVariables(
|
const envFileContent = prepareEnvironmentVariables(
|
||||||
envContent,
|
envContent,
|
||||||
compose.project.env,
|
compose.project.env
|
||||||
).join("\n");
|
).join("\n");
|
||||||
|
|
||||||
if (!existsSync(dirname(envFilePath))) {
|
if (!existsSync(dirname(envFilePath))) {
|
||||||
@@ -232,7 +232,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
|||||||
|
|
||||||
const envFileContent = prepareEnvironmentVariables(
|
const envFileContent = prepareEnvironmentVariables(
|
||||||
envContent,
|
envContent,
|
||||||
compose.project.env,
|
compose.project.env
|
||||||
).join("\n");
|
).join("\n");
|
||||||
|
|
||||||
const encodedContent = encodeBase64(envFileContent);
|
const encodedContent = encodeBase64(envFileContent);
|
||||||
@@ -247,7 +247,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
|
|||||||
|
|
||||||
const envVars = getEnviromentVariablesObject(
|
const envVars = getEnviromentVariablesObject(
|
||||||
compose.env,
|
compose.env,
|
||||||
compose.project.env,
|
compose.project.env
|
||||||
);
|
);
|
||||||
const exports = Object.entries(envVars)
|
const exports = Object.entries(envVars)
|
||||||
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
|
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ComposeSpecification } from "./types";
|
|||||||
|
|
||||||
export const addAppNameToPreventCollision = (
|
export const addAppNameToPreventCollision = (
|
||||||
composeData: ComposeSpecification,
|
composeData: ComposeSpecification,
|
||||||
appName: string,
|
appName: string
|
||||||
): ComposeSpecification => {
|
): ComposeSpecification => {
|
||||||
let updatedComposeData = { ...composeData };
|
let updatedComposeData = { ...composeData };
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ export const addAppNameToPreventCollision = (
|
|||||||
return updatedComposeData;
|
return updatedComposeData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const randomizeDeployableComposeFile = async (
|
export const randomizeIsolatedDeploymentComposeFile = async (
|
||||||
composeId: string,
|
composeId: string,
|
||||||
suffix?: string,
|
suffix?: string
|
||||||
) => {
|
) => {
|
||||||
const compose = await findComposeById(composeId);
|
const compose = await findComposeById(composeId);
|
||||||
const composeFile = compose.composeFile;
|
const composeFile = compose.composeFile;
|
||||||
@@ -28,7 +28,7 @@ export const randomizeDeployableComposeFile = async (
|
|||||||
|
|
||||||
const newComposeFile = addAppNameToPreventCollision(
|
const newComposeFile = addAppNameToPreventCollision(
|
||||||
composeData,
|
composeData,
|
||||||
randomSuffix,
|
randomSuffix
|
||||||
);
|
);
|
||||||
|
|
||||||
return dump(newComposeFile);
|
return dump(newComposeFile);
|
||||||
@@ -36,7 +36,7 @@ export const randomizeDeployableComposeFile = async (
|
|||||||
|
|
||||||
export const randomizeDeployableSpecificationFile = (
|
export const randomizeDeployableSpecificationFile = (
|
||||||
composeSpec: ComposeSpecification,
|
composeSpec: ComposeSpecification,
|
||||||
suffix?: string,
|
suffix?: string
|
||||||
) => {
|
) => {
|
||||||
if (!suffix) {
|
if (!suffix) {
|
||||||
return composeSpec;
|
return composeSpec;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const getComposePath = (compose: Compose) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const loadDockerCompose = async (
|
export const loadDockerCompose = async (
|
||||||
compose: Compose,
|
compose: Compose
|
||||||
): Promise<ComposeSpecification | null> => {
|
): Promise<ComposeSpecification | null> => {
|
||||||
const path = getComposePath(compose);
|
const path = getComposePath(compose);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export const loadDockerCompose = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const loadDockerComposeRemote = async (
|
export const loadDockerComposeRemote = async (
|
||||||
compose: Compose,
|
compose: Compose
|
||||||
): Promise<ComposeSpecification | null> => {
|
): Promise<ComposeSpecification | null> => {
|
||||||
const path = getComposePath(compose);
|
const path = getComposePath(compose);
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +100,7 @@ export const loadDockerComposeRemote = async (
|
|||||||
}
|
}
|
||||||
const { stdout, stderr } = await execAsyncRemote(
|
const { stdout, stderr } = await execAsyncRemote(
|
||||||
compose.serverId,
|
compose.serverId,
|
||||||
`cat ${path}`,
|
`cat ${path}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
@@ -125,7 +125,7 @@ export const readComposeFile = async (compose: Compose) => {
|
|||||||
|
|
||||||
export const writeDomainsToCompose = async (
|
export const writeDomainsToCompose = async (
|
||||||
compose: Compose,
|
compose: Compose,
|
||||||
domains: Domain[],
|
domains: Domain[]
|
||||||
) => {
|
) => {
|
||||||
if (!domains.length) {
|
if (!domains.length) {
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +144,7 @@ export const writeDomainsToCompose = async (
|
|||||||
export const writeDomainsToComposeRemote = async (
|
export const writeDomainsToComposeRemote = async (
|
||||||
compose: Compose,
|
compose: Compose,
|
||||||
domains: Domain[],
|
domains: Domain[],
|
||||||
logPath: string,
|
logPath: string
|
||||||
) => {
|
) => {
|
||||||
if (!domains.length) {
|
if (!domains.length) {
|
||||||
return "";
|
return "";
|
||||||
@@ -175,7 +175,7 @@ exit 1;
|
|||||||
// (node:59875) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGTERM listeners added to [process]. Use emitter.setMaxListeners() to increase limit
|
// (node:59875) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 SIGTERM listeners added to [process]. Use emitter.setMaxListeners() to increase limit
|
||||||
export const addDomainToCompose = async (
|
export const addDomainToCompose = async (
|
||||||
compose: Compose,
|
compose: Compose,
|
||||||
domains: Domain[],
|
domains: Domain[]
|
||||||
) => {
|
) => {
|
||||||
const { appName } = compose;
|
const { appName } = compose;
|
||||||
|
|
||||||
@@ -191,10 +191,10 @@ export const addDomainToCompose = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compose.deployable) {
|
if (compose.isolatedDeployment) {
|
||||||
const randomized = randomizeDeployableSpecificationFile(
|
const randomized = randomizeDeployableSpecificationFile(
|
||||||
result,
|
result,
|
||||||
compose.suffix || compose.appName,
|
compose.suffix || compose.appName
|
||||||
);
|
);
|
||||||
result = randomized;
|
result = randomized;
|
||||||
} else if (compose.randomize) {
|
} else if (compose.randomize) {
|
||||||
@@ -216,7 +216,7 @@ export const addDomainToCompose = async (
|
|||||||
const httpsLabels = await createDomainLabels(
|
const httpsLabels = await createDomainLabels(
|
||||||
appName,
|
appName,
|
||||||
domain,
|
domain,
|
||||||
"websecure",
|
"websecure"
|
||||||
);
|
);
|
||||||
httpLabels.push(...httpsLabels);
|
httpLabels.push(...httpsLabels);
|
||||||
}
|
}
|
||||||
@@ -247,16 +247,16 @@ export const addDomainToCompose = async (
|
|||||||
labels.push(...httpLabels);
|
labels.push(...httpLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!compose.deployable) {
|
if (!compose.isolatedDeployment) {
|
||||||
// Add the dokploy-network to the service
|
// Add the dokploy-network to the service
|
||||||
result.services[serviceName].networks = addDokployNetworkToService(
|
result.services[serviceName].networks = addDokployNetworkToService(
|
||||||
result.services[serviceName].networks,
|
result.services[serviceName].networks
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add dokploy-network to the root of the compose file
|
// Add dokploy-network to the root of the compose file
|
||||||
if (!compose.deployable) {
|
if (!compose.isolatedDeployment) {
|
||||||
result.networks = addDokployNetworkToRoot(result.networks);
|
result.networks = addDokployNetworkToRoot(result.networks);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ export const addDomainToCompose = async (
|
|||||||
|
|
||||||
export const writeComposeFile = async (
|
export const writeComposeFile = async (
|
||||||
compose: Compose,
|
compose: Compose,
|
||||||
composeSpec: ComposeSpecification,
|
composeSpec: ComposeSpecification
|
||||||
) => {
|
) => {
|
||||||
const path = getComposePath(compose);
|
const path = getComposePath(compose);
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ export const writeComposeFile = async (
|
|||||||
export const createDomainLabels = async (
|
export const createDomainLabels = async (
|
||||||
appName: string,
|
appName: string,
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
entrypoint: "web" | "websecure",
|
entrypoint: "web" | "websecure"
|
||||||
) => {
|
) => {
|
||||||
const { host, port, https, uniqueConfigKey, certificateType, path } = domain;
|
const { host, port, https, uniqueConfigKey, certificateType, path } = domain;
|
||||||
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
|
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
|
||||||
@@ -295,14 +295,14 @@ export const createDomainLabels = async (
|
|||||||
|
|
||||||
if (entrypoint === "web" && https) {
|
if (entrypoint === "web" && https) {
|
||||||
labels.push(
|
labels.push(
|
||||||
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,
|
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entrypoint === "websecure") {
|
if (entrypoint === "websecure") {
|
||||||
if (certificateType === "letsencrypt") {
|
if (certificateType === "letsencrypt") {
|
||||||
labels.push(
|
labels.push(
|
||||||
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
|
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ export const createDomainLabels = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const addDokployNetworkToService = (
|
export const addDokployNetworkToService = (
|
||||||
networkService: DefinitionsService["networks"],
|
networkService: DefinitionsService["networks"]
|
||||||
) => {
|
) => {
|
||||||
let networks = networkService;
|
let networks = networkService;
|
||||||
const network = "dokploy-network";
|
const network = "dokploy-network";
|
||||||
@@ -333,7 +333,7 @@ export const addDokployNetworkToService = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const addDokployNetworkToRoot = (
|
export const addDokployNetworkToRoot = (
|
||||||
networkRoot: PropertiesNetworks | undefined,
|
networkRoot: PropertiesNetworks | undefined
|
||||||
) => {
|
) => {
|
||||||
let networks = networkRoot;
|
let networks = networkRoot;
|
||||||
const network = "dokploy-network";
|
const network = "dokploy-network";
|
||||||
|
|||||||
Reference in New Issue
Block a user