Merge pull request #280 from Dokploy/canary

v0.5.0
This commit is contained in:
Mauricio Siu
2024-07-27 15:20:43 -06:00
committed by GitHub
69 changed files with 7940 additions and 975 deletions

1
.gitignore vendored
View File

@@ -52,6 +52,7 @@ yarn-error.log*
# otros
/.data
/.main
.vscode
*.lockb
*.rdb

View File

@@ -0,0 +1,187 @@
import type { Domain } from "@/server/api/services/domain";
import type { Redirect } from "@/server/api/services/redirect";
import type { ApplicationNested } from "@/server/utils/builders";
import { createRouterConfig } from "@/server/utils/traefik/domain";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
applicationId: "",
applicationStatus: "done",
appName: "",
autoDeploy: true,
branch: null,
buildArgs: null,
buildPath: "/",
buildType: "nixpacks",
command: null,
cpuLimit: null,
cpuReservation: null,
createdAt: "",
customGitBranch: "",
customGitBuildPath: "",
customGitSSHKeyId: null,
customGitUrl: "",
description: "",
dockerfile: null,
dockerImage: null,
dropBuildPath: null,
enabled: null,
env: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
memoryReservation: null,
modeSwarm: null,
mounts: [],
name: "",
networkSwarm: null,
owner: null,
password: null,
placementSwarm: null,
ports: [],
projectId: "",
redirects: [],
refreshToken: "",
registry: null,
registryId: null,
replicas: 1,
repository: null,
restartPolicySwarm: null,
rollbackConfigSwarm: null,
security: [],
sourceType: "git",
subtitle: null,
title: null,
updateConfigSwarm: null,
username: null,
};
const baseDomain: Domain = {
applicationId: "",
certificateType: "none",
createdAt: "",
domainId: "",
host: "",
https: false,
path: null,
port: null,
uniqueConfigKey: 1,
};
const baseRedirect: Redirect = {
redirectId: "",
regex: "",
replacement: "",
permanent: false,
uniqueConfigKey: 1,
createdAt: "",
applicationId: "",
};
/** Middlewares */
test("Web entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false },
"web",
);
expect(router.middlewares).not.toContain("redirect-to-https");
});
test("Web entrypoint on http domain with redirect", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, https: false },
"web",
);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("redirect-test-1");
});
test("Web entrypoint on http domain with multiple redirect", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [
{ ...baseRedirect, uniqueConfigKey: 1 },
{ ...baseRedirect, uniqueConfigKey: 2 },
],
},
{ ...baseDomain, https: false },
"web",
);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("redirect-test-2");
});
test("Web entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true },
"web",
);
expect(router.middlewares).toContain("redirect-to-https");
});
test("Web entrypoint on https domain with redirect", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, https: true },
"web",
);
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("redirect-test-1");
});
test("Websecure entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true },
"websecure",
);
expect(router.middlewares).not.toContain("redirect-to-https");
});
test("Websecure entrypoint on https domain with redirect", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, https: true },
"websecure",
);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("redirect-test-1");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, certificateType: "letsencrypt" },
"websecure",
);
expect(router.tls?.certResolver).toBe("letsencrypt");
});

View File

@@ -28,84 +28,103 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
// .regex(hostnameRegex
const addDomain = z.object({
host: z.string().min(1, "Hostname is required"),
path: z.string().min(1),
port: z.number(),
https: z.boolean(),
certificateType: z.enum(["letsencrypt", "none"]),
});
import { domain } from "@/server/db/validations";
import { zodResolver } from "@hookform/resolvers/zod";
import type z from "zod";
type AddDomain = z.infer<typeof addDomain>;
type Domain = z.infer<typeof domain>;
interface Props {
applicationId: string;
children?: React.ReactNode;
domainId?: string;
children: React.ReactNode;
}
export const AddDomain = ({
applicationId,
children = <PlusIcon className="h-4 w-4" />,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error } = api.domain.create.useMutation();
const form = useForm<AddDomain>({
defaultValues: {
host: "",
https: false,
path: "/",
port: 3000,
certificateType: "none",
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
resolver: zodResolver(addDomain),
{
enabled: !!domainId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const form = useForm<Domain>({
resolver: zodResolver(domain),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
});
}
const onSubmit = async (data: AddDomain) => {
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
applicationId,
host: data.host,
https: data.https,
path: data.path,
port: data.port,
certificateType: data.certificateType,
...data,
})
.then(async () => {
toast.success("Domain Created");
toast.success(dictionary.success);
await utils.domain.byApplicationId.invalidate({
applicationId,
});
await utils.application.readTraefikConfig.invalidate({ applicationId });
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the domain");
toast.error(dictionary.error);
});
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button>{children}</Button>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>
In this section you can add custom domains
</DialogDescription>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -169,33 +188,36 @@ export const AddDomain = ({
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
{form.getValues().https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="https"
@@ -206,6 +228,7 @@ export const AddDomain = ({
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
@@ -226,7 +249,7 @@ export const AddDomain = ({
form="hook-form"
type="submit"
>
Create
{dictionary.submit}
</Button>
</DialogFooter>
</Form>

View File

@@ -8,13 +8,11 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, RefreshCcw } from "lucide-react";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { AddDomain } from "./add-domain";
import { DeleteDomain } from "./delete-domain";
import { GenerateDomain } from "./generate-domain";
import { UpdateDomain } from "./update-domain";
interface Props {
applicationId: string;
@@ -43,7 +41,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
{data && data?.length > 0 && (
@@ -61,7 +61,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
<GenerateDomain applicationId={applicationId} />
@@ -90,7 +92,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<UpdateDomain domainId={item.domainId} />
<AddDomain
applicationId={applicationId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomain>
<DeleteDomain domainId={item.domainId} />
</div>
</div>

View File

@@ -1,254 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
const updateDomain = z.object({
host: z.string().regex(hostnameRegex, { message: "Invalid hostname" }),
path: z.string().min(1),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }),
https: z.boolean(),
certificateType: z.enum(["letsencrypt", "none"]),
});
type UpdateDomain = z.infer<typeof updateDomain>;
interface Props {
domainId: string;
}
export const UpdateDomain = ({ domainId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { mutateAsync, isError, error } = api.domain.update.useMutation();
const form = useForm<UpdateDomain>({
defaultValues: {
host: "",
https: true,
path: "/",
port: 3000,
certificateType: "none",
},
resolver: zodResolver(updateDomain),
});
useEffect(() => {
if (data) {
form.reset({
host: data.host || "",
port: data.port || 3000,
path: data.path || "/",
https: data.https,
certificateType: data.certificateType,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateDomain) => {
await mutateAsync({
domainId,
host: data.host,
https: data.https,
path: data.path,
port: data.port,
certificateType: data.certificateType,
})
.then(async (data) => {
toast.success("Domain Updated");
await refetch();
await utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
await utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
})
.catch(() => {
toast.error("Error to update the domain");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>
In this section you can add custom domains
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="https"
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>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,30 +1,16 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Toggle } from "@/components/ui/toggle";
import { Card, CardContent } from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addEnvironmentSchema = z.object({
environment: z.string(),
env: z.string(),
buildArgs: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -34,7 +20,6 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } =
api.application.saveEnvironment.useMutation();
@@ -46,24 +31,19 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
enabled: !!applicationId,
},
);
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
env: data?.env || "",
buildArgs: data?.buildArgs || "",
},
resolver: zodResolver(addEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: data.environment,
env: data.env,
buildArgs: data.buildArgs,
applicationId,
})
.then(async () => {
@@ -74,94 +54,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
toast.error("Error to add environment");
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-4"
>
<FormField
control={form.control}
name="environment"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-5 "
>
<Card className="bg-background">
<Secrets
name="env"
title="Environment Settings"
description="You can add environment variables to your resource."
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/>
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
description={
<span>
Available only at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/guide/build-args/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
<CardContent>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
</div>
</CardContent>
</Card>
</form>
</Form>
);
};

View File

@@ -1,13 +1,4 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
@@ -17,11 +8,20 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -33,6 +33,7 @@ const GitProviderSchema = z.object({
}),
branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -43,19 +44,18 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } =
api.application.saveGitProdiver.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.application.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.application.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -63,6 +63,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
useEffect(() => {
if (data) {
form.reset({
sshKey: data.customGitSSHKeyId || undefined,
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
@@ -75,6 +76,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitBranch: values.branch,
customGitBuildPath: values.buildPath,
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
})
.then(async () => {
@@ -92,160 +94,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
applicationId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
applicationId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
</div>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "}
Save
</Button>
</div>
</form>

View File

@@ -1,13 +1,4 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
@@ -17,11 +8,19 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -33,6 +32,7 @@ const GitProviderSchema = z.object({
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -43,19 +43,17 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
repositoryURL: "",
composePath: "./docker-compose.yml",
sshKey: undefined,
},
resolver: zodResolver(GitProviderSchema),
});
@@ -63,6 +61,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
useEffect(() => {
if (data) {
form.reset({
sshKey: data.customGitSSHKeyId || undefined,
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
@@ -74,6 +73,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
customGitBranch: values.branch,
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
composeId,
sourceType: "git",
composePath: values.composePath,
@@ -94,123 +94,72 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
</div>
<div className="space-y-4">
<FormField

View File

@@ -30,12 +30,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
appType: "stack" | "docker-compose";
}
export const ShowDockerLogsCompose = ({ appName }: Props) => {
export const ShowDockerLogsCompose = ({ appName, appType }: Props) => {
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
},
{
enabled: !!appName,

View File

@@ -31,6 +31,7 @@ export const ShowMonitoringCompose = ({
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
appType,
},
{
enabled: !!appName,

View File

@@ -23,8 +23,11 @@ export const DockerLogsId: React.FC<Props> = ({ id, containerId }) => {
cursorBlink: true,
cols: 80,
rows: 30,
lineHeight: 1.4,
lineHeight: 1.25,
fontWeight: 400,
fontSize: 14,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
convertEol: true,
theme: {

View File

@@ -90,6 +90,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
form,
data?.databaseName,
data?.databaseUser,
ip,
]);
return (
<>

View File

@@ -90,6 +90,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
data?.databasePassword,
form,
data?.databaseUser,
ip,
]);
return (

View File

@@ -91,6 +91,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
data?.databaseName,
data?.databaseUser,
form,
ip,
]);
return (
<>

View File

@@ -92,6 +92,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
data?.databasePassword,
form,
data?.databaseName,
ip,
]);
return (

View File

@@ -85,7 +85,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
};
setConnectionUrl(buildConnectionUrl());
}, [data?.appName, data?.externalPort, data?.databasePassword, form]);
}, [data?.appName, data?.externalPort, data?.databasePassword, form, ip]);
return (
<>
<div className="flex w-full flex-col gap-5 ">

View File

@@ -0,0 +1,200 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { sshKeyCreate, type sshKeyType } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { type ReactNode, useState } from "react";
import { flushSync } from "react-dom";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyCreate>;
interface Props {
children: ReactNode;
}
export const AddSSHKey = ({ children }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isError, error, isLoading } =
api.sshKey.create.useMutation();
const generateMutation = api.sshKey.generate.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyCreate),
});
const onSubmit = async (data: SSHKey) => {
await mutateAsync(data)
.then(async () => {
toast.success("SSH key created successfully");
await utils.sshKey.all.invalidate();
/*
Flushsync is needed for a bug witht he react-hook-form reset method
https://github.com/orgs/react-hook-form/discussions/7589#discussioncomment-10060621
*/
flushSync(() => form.reset());
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the SSH key");
});
};
const onGenerateSSHKey = (type: z.infer<typeof sshKeyType>) =>
generateMutation
.mutateAsync(type)
.then(async (data) => {
toast.success("SSH Key Generated");
form.setValue("privateKey", data.privateKey);
form.setValue("publicKey", data.publicKey);
})
.catch(() => {
toast.error("Error to generate the SSH Key");
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Key</DialogTitle>
<DialogDescription className="space-y-4">
<div>
In this section you can add one of your keys or generate a new
one.
</div>
<div className="flex gap-4">
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() => onGenerateSSHKey("rsa")}
type="button"
>
Generate RSA SSH Key
</Button>
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() => onGenerateSSHKey("ed25519")}
type="button"
>
Generate ED25519 SSH Key
</Button>
</div>
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
className="grid w-full gap-4 "
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Personal projects"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={"Used on my personal Hetzner VPS"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormControl>
<Textarea
placeholder={"-----BEGIN RSA PRIVATE KEY-----"}
rows={5}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Public Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"ssh-rsa AAAAB3NzaC1yc2E"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,61 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
sshKeyId: string;
}
export const DeleteSSHKey = ({ sshKeyId }: Props) => {
const { mutateAsync, isLoading } = api.sshKey.remove.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the SSH
key
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
sshKeyId,
})
.then(() => {
utils.sshKey.all.invalidate();
toast.success("SSH Key delete successfully");
})
.catch(() => {
toast.error("Error to delete SSH key");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,96 @@
import { UpdateSSHKey } from "@/components/dashboard/settings/ssh-keys/update-ssh-key";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { formatDistanceToNow } from "date-fns";
import { KeyRound, KeyRoundIcon, PenBoxIcon } from "lucide-react";
import { AddSSHKey } from "./add-ssh-key";
import { DeleteSSHKey } from "./delete-ssh-key";
export const ShowDestinations = () => {
const { data } = api.sshKey.all.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">SSH Keys</CardTitle>
<CardDescription>
Use SSH to beeing able cloning from private repositories.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<KeyRound className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Add your first SSH Key
</span>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
) : (
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div className="flex gap-4 text-xs px-3.5">
<div className="col-span-2 basis-4/12">Key</div>
<div className="basis-3/12">Added</div>
<div>Last Used</div>
</div>
{data?.map((sshKey) => (
<div
key={sshKey.sshKeyId}
className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
>
<div className="flex flex-col basis-4/12">
<span>{sshKey.name}</span>
{sshKey.description && (
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
)}
</div>
<div className="basis-3/12">
{formatDistanceToNow(new Date(sshKey.createdAt), {
addSuffix: true,
})}
</div>
<div className="grow">
{sshKey.lastUsedAt
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
addSuffix: true,
})
: "Never"}
</div>
<div className="flex flex-row gap-1">
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</UpdateSSHKey>
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
</div>
</div>
))}
</div>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,168 @@
import { DeleteSSHKey } from "@/components/dashboard/settings/ssh-keys/delete-ssh-key";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { sshKeyUpdate } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyUpdate>;
interface Props {
children: ReactNode;
sshKeyId?: string;
}
export const UpdateSSHKey = ({ children, sshKeyId = "" }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.sshKey.one.useQuery({
sshKeyId,
});
const { mutateAsync, isError, error, isLoading } =
api.sshKey.update.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyUpdate),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
description: data.description || undefined,
});
}
}, [data]);
const onSubmit = async (data: SSHKey) => {
await mutateAsync({
sshKeyId,
...data,
})
.then(async () => {
toast.success("SSH Key Updated");
await utils.sshKey.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the SSH key");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Key</DialogTitle>
<DialogDescription>
In this section you can edit an SSH key
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
className="grid w-full gap-4 "
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Personal projects"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={"Used on my personal Hetzner VPS"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormItem>
<FormLabel>Public Key</FormLabel>
<FormControl>
<div className="relative">
<Textarea
rows={7}
readOnly
disabled
value={data?.publicKey}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(data?.publicKey || "Generate a SSH Key");
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import React, { useEffect, useState } from "react";
import React from "react";
import {
DropdownMenu,
@@ -37,6 +37,9 @@ export const WebServer = () => {
api.settings.reloadTraefik.useMutation();
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
api.settings.cleanAll.useMutation();
const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } =
api.settings.toggleDashboard.useMutation();
const {
mutateAsync: cleanDockerBuilder,
isLoading: cleanDockerBuilderIsLoading,
@@ -107,6 +110,7 @@ export const WebServer = () => {
<span>View Traefik config</span>
</DropdownMenuItem>
</ShowServerTraefikConfig>
<ShowServerMiddlewareConfig>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
@@ -124,8 +128,14 @@ export const WebServer = () => {
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={reloadTraefikIsLoading}>
<Button isLoading={reloadTraefikIsLoading} variant="outline">
<DropdownMenuTrigger
asChild
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
>
<Button
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
variant="outline"
>
Traefik
</Button>
</DropdownMenuTrigger>
@@ -157,6 +167,38 @@ export const WebServer = () => {
<span>View Traefik config</span>
</DropdownMenuItem>
</ShowMainTraefikConfig>
<DropdownMenuItem
onClick={async () => {
await toggleDashboard({
enableDashboard: true,
})
.then(async () => {
toast.success("Dashboard Enabled");
})
.catch(() => {
toast.error("Error to enable Dashboard");
});
}}
className="w-full cursor-pointer space-x-3"
>
<span>Enable Dashboard</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await toggleDashboard({
enableDashboard: false,
})
.then(async () => {
toast.success("Dashboard Disabled");
})
.catch(() => {
toast.error("Error to disable Dashboard");
});
}}
className="w-full cursor-pointer space-x-3"
>
<span>Disable Dashboard</span>
</DropdownMenuItem>
<DockerTerminalModal appName="dokploy-traefik">
<DropdownMenuItem

View File

@@ -53,6 +53,12 @@ export const SettingsLayout = ({ children }: Props) => {
icon: ShieldCheck,
href: "/dashboard/settings/certificates",
},
{
title: "SSH Keys",
label: "",
icon: KeyRound,
href: "/dashboard/settings/ssh-keys",
},
{
title: "Users",
label: "",
@@ -86,6 +92,7 @@ import {
Activity,
Bell,
Database,
KeyRound,
type LucideIcon,
Route,
Server,

78
components/ui/secrets.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { CodeEditor } from "@/components/shared/code-editor";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Toggle } from "@/components/ui/toggle";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import { useFormContext } from "react-hook-form";
interface Props {
name: string;
title: string;
description: ReactNode;
placeholder: string;
}
export const Secrets = (props: Props) => {
const [isVisible, setIsVisible] = useState(true);
const form = useFormContext<Record<string, string>>();
return (
<>
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isVisible}
onPressedChange={setIsVisible}
>
{isVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent className="w-full space-y-4">
<FormField
control={form.control}
name={props.name}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CodeEditor
style={
{
WebkitTextSecurity: isVisible ? "disc" : null,
} as CSSProperties
}
language="properties"
disabled={isVisible}
placeholder={props.placeholder}
className="h-96 font-mono"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</>
);
};

View File

@@ -57,7 +57,7 @@ echo "Network created"
mkdir -p /etc/dokploy
chmod -R 777 /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:canary

View File

@@ -30,11 +30,6 @@ if ss -tulnp | grep ':443 ' >/dev/null; then
exit 1
fi
command_exists() {
command -v "$@" > /dev/null 2>&1
}
@@ -46,7 +41,25 @@ else
fi
docker swarm leave --force 2>/dev/null
docker swarm init;
get_ip() {
# Try to get IPv4
local ipv4=$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv4" ]; then
echo "$ipv4"
else
# Try to get IPv6
local ipv6=$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "$ipv6" ]; then
echo "$ipv6"
fi
fi
}
advertise_addr=$(get_ip)
docker swarm init --advertise-addr $advertise_addr
echo "Swarm initialized"
@@ -57,7 +70,7 @@ echo "Network created"
mkdir -p /etc/dokploy
chmod -R 777 /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:latest
@@ -71,19 +84,28 @@ docker service create \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role == manager' \
dokploy/dokploy:latest
public_ip=$(hostname -I | awk '{print $1}')
GREEN="\033[0;32m"
YELLOW="\033[1;33m"
BLUE="\033[0;34m"
NC="\033[0m" # No Color
format_ip_for_url() {
local ip="$1"
if echo "$ip" | grep -q ':'; then
# IPv6
echo "[${ip}]"
else
# IPv4
echo "${ip}"
fi
}
formatted_addr=$(format_ip_for_url "$advertise_addr")
echo ""
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
printf "${YELLOW}Please go to http://${public_ip}:3000${NC}\n\n"
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
echo ""

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "buildArgs" text;

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "ssh-key" (
"sshKeyId" text PRIMARY KEY NOT NULL,
"publicKey" text NOT NULL,
"name" text NOT NULL,
"description" text,
"createdAt" text NOT NULL,
"lastUsedAt" text
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "application" ADD CONSTRAINT "application_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "compose" ADD CONSTRAINT "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "application" DROP COLUMN IF EXISTS "customGitSSHKey";--> statement-breakpoint
ALTER TABLE "compose" DROP COLUMN IF EXISTS "customGitSSHKey";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,20 @@
"when": 1721603595092,
"tag": "0024_dapper_supernaut",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1721633853118,
"tag": "0025_lying_mephisto",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721979220929,
"tag": "0026_known_dormammu",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.4.0",
"version": "v0.5.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -17,7 +17,7 @@
"migration:run": "tsx -r dotenv/config migration.ts",
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
"migration:drop": "drizzle-kit drop --config ./server/db/drizzle.config.ts",
"db:push": "drizzle-kit --config ./server/db/drizzle.config.ts",
"db:push": "drizzle-kit push --config ./server/db/drizzle.config.ts",
"db:truncate": "tsx -r dotenv/config ./server/db/reset.ts",
"db:studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",

View File

@@ -156,7 +156,10 @@ const Service = (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogsCompose appName={data?.appName || ""} />
<ShowDockerLogsCompose
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
</div>
</TabsContent>

View File

@@ -0,0 +1,41 @@
import { ShowDestinations } from "@/components/dashboard/settings/ssh-keys/show-ssh-keys";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { validateRequest } from "@/server/auth/auth";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowDestinations />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user || user.rol === "user") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg id="banner-light" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
<defs>
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#aa5cc3"/>
<stop offset="1" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<title>banner-light</title>
<g id="banner-light">
<g id="banner-light-icon">
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
</g>
<g id="jellyfin-dark-outlines" style="isolation:isolate" transform="translate(43.8)">
<path d="M556.64,347.78a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.28,9.28,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V156.07a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.61,358,589.9,358A57.21,57.21,0,0,1,556.64,347.78Z"/>
<path d="M831.66,276.5a8.77,8.77,0,0,1-6.24,2.53H713.15q0,17.82,7.28,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.17,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.85,8.85,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,358q-22.87,0-40.4-9.8a69.44,69.44,0,0,1-27.32-27.48q-9.79-17.65-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,208a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,276.5ZM733.5,228.83Q718.8,240.72,714.64,263H815.92v-2.38A47,47,0,0,0,807,237.3a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,217,733.5,228.83Z"/>
<path d="M888.24,352.53a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,352.53Z"/>
<path d="M956.55,352.53a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,352.53Z"/>
<path d="M1122.86,203.14a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a97.24,97.24,0,0,1-23.31-2.67,62,62,0,0,1-18-7.13q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.8,7.8,0,0,1,3.56.9q19,10.38,36.24,10.39,24.65,0,39.05-15.44t14.41-42.18V330.41a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.85,0-39.2-15.45t-13.37-41.87V209.53a8.91,8.91,0,1,1,17.82,0V298q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24v-88.5a9,9,0,0,1,15.29-6.39Z"/>
<path d="M1210.18,158.44q-5.21,6.24-5.2,17.23v30.59h33.27a8.25,8.25,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.24,8.24,0,0,1-5.79,2.37H1205V346.15a8.91,8.91,0,1,1-17.82,0V222.89h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V175.67q0-18.71,10.84-29t29-10.24A46,46,0,0,1,1242.4,139q7.14,2.53,7.13,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,152.21,1210.18,158.44ZM1276,177.9c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,179.78,1276,177.9Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V209.53a8.91,8.91,0,1,1,17.82,0V346.15A8.65,8.65,0,0,1,1291.56,352.53Z"/>
<path d="M1452.53,215.91q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V259.72q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.26,36.26,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V213.09a8.91,8.91,0,1,1,17.82,0v16.34q8-12.77,23-21.24a61.81,61.81,0,0,1,30.74-8.46Q1439.61,199.73,1452.53,215.91Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -23,6 +23,7 @@ import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { securityRouter } from "./routers/security";
import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { userRouter } from "./routers/user";
/**
@@ -56,6 +57,7 @@ export const appRouter = createTRPCRouter({
registry: registryRouter,
cluster: clusterRouter,
notification: notificationRouter,
sshKey: sshRouter,
});
// export type definition of API

View File

@@ -31,11 +31,6 @@ import {
removeDirectoryCode,
removeMonitoringDirectory,
} from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import {
readConfig,
removeTraefikConfig,
@@ -130,7 +125,6 @@ export const applicationRouter = createTRPCRouter({
async () => await removeMonitoringDirectory(application?.appName),
async () => await removeTraefikConfig(application?.appName),
async () => await removeService(application?.appName),
async () => await removeRSAFiles(application?.appName),
];
for (const operation of cleanupOperations) {
@@ -187,6 +181,7 @@ export const applicationRouter = createTRPCRouter({
.mutation(async ({ input }) => {
await updateApplication(input.applicationId, {
env: input.env,
buildArgs: input.buildArgs,
});
return true;
}),
@@ -234,36 +229,11 @@ export const applicationRouter = createTRPCRouter({
customGitBranch: input.customGitBranch,
customGitBuildPath: input.customGitBuildPath,
customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git",
applicationStatus: "idle",
});
return true;
}),
generateSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
try {
await generateSSHKey(application.appName);
const file = await readRSAFile(application.appName);
await updateApplication(input.applicationId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
await removeRSAFiles(application.appName);
await updateApplication(input.applicationId, {
customGitSSHKey: null,
});
return true;
}),
markRunning: protectedProcedure

View File

@@ -16,11 +16,6 @@ import { myQueue } from "@/server/queues/queueSetup";
import { createCommand } from "@/server/utils/builders/compose";
import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import { templates } from "@/templates/templates";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import {
@@ -102,7 +97,6 @@ export const composeRouter = createTRPCRouter({
async () => await removeCompose(composeResult),
async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName),
async () => await removeRSAFiles(composeResult.appName),
];
for (const operation of cleanupOperations) {
@@ -181,38 +175,12 @@ export const composeRouter = createTRPCRouter({
const command = createCommand(compose);
return `docker ${command}`;
}),
generateSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
try {
await generateSSHKey(compose.appName);
const file = await readRSAFile(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
await removeRSAFiles(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: null,
});
return true;
}),
deployTemplate: protectedProcedure

View File

@@ -25,11 +25,14 @@ export const dockerRouter = createTRPCRouter({
getContainersByAppNameMatch: protectedProcedure
.input(
z.object({
appType: z
.union([z.literal("stack"), z.literal("docker-compose")])
.optional(),
appName: z.string().min(1),
}),
)
.query(async ({ input }) => {
return await getContainersByAppNameMatch(input.appName);
return await getContainersByAppNameMatch(input.appName, input.appType);
}),
getContainersByAppLabel: protectedProcedure

View File

@@ -1,12 +1,14 @@
import { MAIN_TRAEFIK_PATH, MONITORING_PATH, docker } from "@/server/constants";
import {
apiAssignDomain,
apiEnableDashboard,
apiModifyTraefikConfig,
apiReadTraefikConfig,
apiSaveSSHKey,
apiTraefikConfig,
apiUpdateDockerCleanup,
} from "@/server/db/schema";
import { initializeTraefik } from "@/server/setup/traefik-setup";
import {
cleanStoppedContainers,
cleanUpDockerBuilder,
@@ -67,6 +69,13 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
toggleDashboard: adminProcedure
.input(apiEnableDashboard)
.mutation(async ({ input }) => {
await initializeTraefik(input.enableDashboard);
return true;
}),
cleanUnusedImages: adminProcedure.mutation(async () => {
await cleanUpUnusedImages();
return true;

View File

@@ -0,0 +1,70 @@
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateSshKey,
apiFindOneSshKey,
apiGenerateSSHKey,
apiRemoveSshKey,
apiUpdateSshKey,
} from "@/server/db/schema";
import { generateSSHKey } from "@/server/utils/filesystem/ssh";
import { TRPCError } from "@trpc/server";
import {
createSshKey,
findSSHKeyById,
removeSSHKeyById,
updateSSHKeyById,
} from "../services/ssh-key";
export const sshRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateSshKey)
.mutation(async ({ input }) => {
try {
await createSshKey(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the ssh key",
cause: error,
});
}
}),
remove: adminProcedure.input(apiRemoveSshKey).mutation(async ({ input }) => {
try {
return await removeSSHKeyById(input.sshKeyId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to delete this ssh key",
});
}
}),
one: protectedProcedure.input(apiFindOneSshKey).query(async ({ input }) => {
const sshKey = await findSSHKeyById(input.sshKeyId);
return sshKey;
}),
all: adminProcedure.query(async () => {
return await db.query.sshKeys.findMany({});
}),
generate: protectedProcedure
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {
return await generateSSHKey(input);
}),
update: adminProcedure.input(apiUpdateSshKey).mutation(async ({ input }) => {
try {
return await updateSSHKeyById(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to update this ssh key",
cause: error,
});
}
}),
});

View File

@@ -66,10 +66,18 @@ export const getConfig = async (containerId: string) => {
} catch (error) {}
};
export const getContainersByAppNameMatch = async (appName: string) => {
export const getContainersByAppNameMatch = async (
appName: string,
appType?: "stack" | "docker-compose",
) => {
try {
const cmd =
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'";
const { stdout, stderr } = await execAsync(
`docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}' | grep ${appName}`,
appType === "docker-compose"
? `${cmd} --filter='label=com.docker.compose.project=${appName}'`
: `${cmd} | grep ${appName}`,
);
if (stderr) {

View File

@@ -0,0 +1,78 @@
import { db } from "@/server/db";
import {
type apiCreateSshKey,
type apiFindOneSshKey,
type apiRemoveSshKey,
type apiUpdateSshKey,
sshKeys,
} from "@/server/db/schema";
import { removeSSHKey, saveSSHKey } from "@/server/utils/filesystem/ssh";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export const createSshKey = async ({
privateKey,
...input
}: typeof apiCreateSshKey._type) => {
await db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
.values(input)
.returning()
.then((response) => response[0])
.catch((e) => console.error(e));
if (sshKey) {
saveSSHKey(sshKey.sshKeyId, sshKey.publicKey, privateKey);
}
if (!sshKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the ssh key",
});
}
return sshKey;
});
};
export const removeSSHKeyById = async (
sshKeyId: (typeof apiRemoveSshKey._type)["sshKeyId"],
) => {
const result = await db
.delete(sshKeys)
.where(eq(sshKeys.sshKeyId, sshKeyId))
.returning();
removeSSHKey(sshKeyId);
return result[0];
};
export const updateSSHKeyById = async ({
sshKeyId,
...input
}: typeof apiUpdateSshKey._type) => {
const result = await db
.update(sshKeys)
.set(input)
.where(eq(sshKeys.sshKeyId, sshKeyId))
.returning();
return result[0];
};
export const findSSHKeyById = async (
sshKeyId: (typeof apiFindOneSshKey._type)["sshKeyId"],
) => {
const sshKey = await db.query.sshKeys.findFirst({
where: eq(sshKeys.sshKeyId, sshKeyId),
});
if (!sshKey) {
throw new TRPCError({
code: "NOT_FOUND",
message: "SSH Key not found",
});
}
return sshKey;
};

View File

@@ -93,3 +93,7 @@ export const apiModifyTraefikConfig = z.object({
export const apiReadTraefikConfig = z.object({
path: z.string().min(1),
});
export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(),
});

View File

@@ -20,6 +20,7 @@ import { redirects } from "./redirects";
import { registry } from "./registry";
import { security } from "./security";
import { applicationStatus } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [
@@ -107,6 +108,7 @@ export const applications = pgTable("application", {
.unique(),
description: text("description"),
env: text("env"),
buildArgs: text("buildArgs"),
memoryReservation: integer("memoryReservation"),
memoryLimit: integer("memoryLimit"),
cpuReservation: integer("cpuReservation"),
@@ -131,7 +133,12 @@ export const applications = pgTable("application", {
customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"),
customGitBuildPath: text("customGitBuildPath"),
customGitSSHKey: text("customGitSSHKey"),
customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
dockerfile: text("dockerfile"),
// Drop
dropBuildPath: text("dropBuildPath"),
@@ -169,6 +176,10 @@ export const applicationsRelations = relations(
references: [projects.projectId],
}),
deployments: many(deployments),
customGitSSHKey: one(sshKeys, {
fields: [applications.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
domains: many(domains),
mounts: many(mounts),
redirects: many(redirects),
@@ -275,6 +286,7 @@ const createSchema = createInsertSchema(applications, {
applicationId: z.string(),
autoDeploy: z.boolean(),
env: z.string().optional(),
buildArgs: z.string().optional(),
name: z.string().min(1),
description: z.string().optional(),
memoryReservation: z.number().optional(),
@@ -287,7 +299,7 @@ const createSchema = createInsertSchema(applications, {
dockerImage: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
customGitSSHKey: z.string().optional(),
customGitSSHKeyId: z.string().optional(),
repository: z.string().optional(),
dockerfile: z.string().optional(),
branch: z.string().optional(),
@@ -369,12 +381,18 @@ export const apiSaveGitProvider = createSchema
customGitBuildPath: true,
customGitUrl: true,
})
.required();
.required()
.merge(
createSchema.pick({
customGitSSHKeyId: true,
}),
);
export const apiSaveEnvironmentVariables = createSchema
.pick({
applicationId: true,
env: true,
buildArgs: true,
})
.required();

View File

@@ -1,3 +1,4 @@
import { sshKeys } from "@/server/db/schema/ssh-key";
import { generatePassword } from "@/templates/utils";
import { relations } from "drizzle-orm";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
@@ -41,7 +42,12 @@ export const compose = pgTable("compose", {
// Git
customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"),
customGitSSHKey: text("customGitSSHKey"),
customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
//
command: text("command").notNull().default(""),
//
@@ -62,6 +68,10 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
}),
deployments: many(deployments),
mounts: many(mounts),
customGitSSHKey: one(sshKeys, {
fields: [compose.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
}));
const createSchema = createInsertSchema(compose, {
@@ -70,6 +80,7 @@ const createSchema = createInsertSchema(compose, {
env: z.string().optional(),
composeFile: z.string().min(1),
projectId: z.string(),
customGitSSHKeyId: z.string().optional(),
command: z.string().optional(),
composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(),

View File

@@ -1,8 +1,8 @@
import { domain } from "@/server/db/validations";
import { relations } from "drizzle-orm";
import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { certificateType } from "./shared";
@@ -31,27 +31,17 @@ export const domainsRelations = relations(domains, ({ one }) => ({
references: [applications.applicationId],
}),
}));
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
const createSchema = createInsertSchema(domains, {
domainId: z.string().min(1),
host: z.string().min(1),
path: z.string().min(1),
port: z.number(),
https: z.boolean(),
applicationId: z.string(),
certificateType: z.enum(["letsencrypt", "none"]),
});
export const apiCreateDomain = createSchema
.pick({
host: true,
path: true,
port: true,
https: true,
applicationId: true,
certificateType: true,
})
.required();
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
export const apiCreateDomain = createSchema.pick({
host: true,
path: true,
port: true,
https: true,
applicationId: true,
certificateType: true,
});
export const apiFindDomain = createSchema
.pick({
@@ -59,19 +49,16 @@ export const apiFindDomain = createSchema
})
.required();
export const apiFindDomainByApplication = createSchema
.pick({
applicationId: true,
})
.required();
export const apiFindDomainByApplication = createSchema.pick({
applicationId: true,
});
export const apiUpdateDomain = createSchema
.pick({
domainId: true,
host: true,
path: true,
port: true,
https: true,
certificateType: true,
})
.required();
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -22,3 +22,4 @@ export * from "./shared";
export * from "./compose";
export * from "./registry";
export * from "./notification";
export * from "./ssh-key";

View File

@@ -0,0 +1,69 @@
import { applications } from "@/server/db/schema/application";
import { compose } from "@/server/db/schema/compose";
import { sshKeyCreate, sshKeyType } from "@/server/db/validations";
import { relations } from "drizzle-orm";
import { pgTable, text, time } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
export const sshKeys = pgTable("ssh-key", {
sshKeyId: text("sshKeyId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
publicKey: text("publicKey").notNull(),
name: text("name").notNull(),
description: text("description"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
lastUsedAt: text("lastUsedAt"),
});
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
applications: many(applications),
compose: many(compose),
}));
const createSchema = createInsertSchema(
sshKeys,
/* Private key is not stored in the DB */
sshKeyCreate.omit({ privateKey: true }).shape,
);
export const apiCreateSshKey = createSchema
.pick({
name: true,
description: true,
publicKey: true,
})
.merge(sshKeyCreate.pick({ privateKey: true }));
export const apiFindOneSshKey = createSchema
.pick({
sshKeyId: true,
})
.required();
export const apiGenerateSSHKey = sshKeyType;
export const apiRemoveSshKey = createSchema
.pick({
sshKeyId: true,
})
.required();
export const apiUpdateSshKey = createSchema
.pick({
name: true,
description: true,
lastUsedAt: true,
})
.partial()
.merge(
createSchema
.pick({
sshKeyId: true,
})
.required(),
);

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
export const sshKeyCreate = z.object({
name: z.string().min(1),
description: z.string().optional(),
publicKey: z.string().refine(
(key) => {
const rsaPubPattern = /^ssh-rsa\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/;
const ed25519PubPattern = /^ssh-ed25519\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/;
return rsaPubPattern.test(key) || ed25519PubPattern.test(key);
},
{
message: "Invalid public key format",
},
),
privateKey: z.string().refine(
(key) => {
const rsaPrivPattern =
/^-----BEGIN RSA PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END RSA PRIVATE KEY-----\s*$/;
const ed25519PrivPattern =
/^-----BEGIN OPENSSH PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END OPENSSH PRIVATE KEY-----\s*$/;
return rsaPrivPattern.test(key) || ed25519PrivPattern.test(key);
},
{
message: "Invalid private key format",
},
),
});
export const sshKeyUpdate = sshKeyCreate.pick({
name: true,
description: true,
});
export const sshKeyType = z.enum(["rsa", "ed25519"]).optional();
export const domain = z
.object({
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/, {
message: "Invalid hostname",
}),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
});

View File

@@ -1,4 +1,5 @@
import { existsSync, mkdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync } from "node:fs";
import {
APPLICATIONS_PATH,
BASE_PATH,
@@ -32,6 +33,9 @@ export const setupDirectories = () => {
for (const dir of directories) {
try {
createDirectoryIfNotExist(dir);
if (dir === SSH_PATH) {
chmodSync(SSH_PATH, "700");
}
} catch (error) {
console.log(error, " On path: ", dir);
}

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { CreateServiceOptions } from "dockerode";
import { dump } from "js-yaml";
@@ -11,7 +11,7 @@ const TRAEFIK_SSL_PORT =
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT ?? "", 10) || 80;
export const initializeTraefik = async () => {
export const initializeTraefik = async (enableDashboard = false) => {
const imageName = "traefik:v2.5";
const containerName = "dokploy-traefik";
const settings: CreateServiceOptions = {
@@ -59,11 +59,15 @@ export const initializeTraefik = async () => {
PublishedPort: TRAEFIK_PORT,
PublishMode: "host",
},
{
TargetPort: 8080,
PublishedPort: 8080,
PublishMode: "host",
},
...(enableDashboard
? [
{
TargetPort: 8080,
PublishedPort: 8080,
PublishMode: "host" as const,
},
]
: []),
],
},
};
@@ -86,6 +90,7 @@ export const initializeTraefik = async () => {
export const createDefaultServerTraefikConfig = () => {
const configFilePath = path.join(DYNAMIC_TRAEFIK_PATH, "dokploy.yml");
if (existsSync(configFilePath)) {
console.log("Default traefik config already exists");
return;
@@ -125,6 +130,11 @@ export const createDefaultServerTraefikConfig = () => {
export const createDefaultTraefikConfig = () => {
const mainConfig = path.join(MAIN_TRAEFIK_PATH, "traefik.yml");
const acmeJsonPath = path.join(DYNAMIC_TRAEFIK_PATH, "acme.json");
if (existsSync(acmeJsonPath)) {
chmodSync(acmeJsonPath, "600");
}
if (existsSync(mainConfig)) {
console.log("Main config already exists");
return;

View File

@@ -58,7 +58,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write("Docker Compose Deployed: ✅");
} catch (error) {
writeStream.write(`ERROR: ${error}: ❌`);
writeStream.write("Error ❌");
throw error;
} finally {
writeStream.end();

View File

@@ -1,40 +1,42 @@
import type { WriteStream } from "node:fs";
import { docker } from "@/server/constants";
import * as tar from "tar-fs";
import { prepareEnvironmentVariables } from "@/server/utils/docker/utils";
import type { ApplicationNested } from ".";
import { getBuildAppDirectory } from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync";
import { createEnvFile } from "./utils";
export const buildCustomDocker = async (
application: ApplicationNested,
writeStream: WriteStream,
) => {
const { appName, env } = application;
const { appName, env, buildArgs } = application;
const dockerFilePath = getBuildAppDirectory(application);
try {
const image = `${appName}`;
const contextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const tarStream = tar.pack(contextPath);
const args = prepareEnvironmentVariables(buildArgs);
const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."];
for (const arg of args) {
commandArgs.push("--build-arg", arg);
}
createEnvFile(dockerFilePath, env);
const stream = await docker.buildImage(tarStream, {
t: image,
dockerfile: dockerFilePath.substring(dockerFilePath.lastIndexOf("/") + 1),
});
await new Promise((resolve, reject) => {
docker.modem.followProgress(
stream,
(err, res) => (err ? reject(err) : resolve(res)),
(event) => {
if (event.stream) {
writeStream.write(event.stream);
}
},
);
});
await spawnAsync(
"docker",
commandArgs,
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
{
cwd: contextPath,
},
);
} catch (error) {
throw error;
}

View File

@@ -51,7 +51,7 @@ export const buildApplication = async (
await mechanizeDockerContainer(application);
writeStream.write("Docker Deployed: ✅");
} catch (error) {
writeStream.write(`ERROR: ${error}: ❌`);
writeStream.write("Error ❌");
throw error;
} finally {
writeStream.end();

View File

@@ -161,6 +161,21 @@ export const removeService = async (appName: string) => {
export const prepareEnvironmentVariables = (env: string | null) =>
Object.entries(parse(env ?? "")).map(([key, value]) => `${key}=${value}`);
export const prepareBuildArgs = (input: string | null) => {
const pairs = (input ?? "").split("\n");
const jsonObject: Record<string, string> = {};
for (const pair of pairs) {
const [key, value] = pair.split("=");
if (key && value) {
jsonObject[key] = value;
}
}
return jsonObject;
};
export const generateVolumeMounts = (mounts: ApplicationNested["mounts"]) => {
if (!mounts || mounts.length === 0) {
return [];

View File

@@ -3,57 +3,90 @@ import * as path from "node:path";
import { SSH_PATH } from "@/server/constants";
import { spawnAsync } from "../process/spawnAsync";
export const generateSSHKey = async (appName: string) => {
const readSSHKey = async (id: string) => {
try {
if (!fs.existsSync(SSH_PATH)) {
fs.mkdirSync(SSH_PATH, { recursive: true });
}
return {
privateKey: fs.readFileSync(path.join(SSH_PATH, `${id}_rsa`), {
encoding: "utf-8",
}),
publicKey: fs.readFileSync(path.join(SSH_PATH, `${id}_rsa.pub`), {
encoding: "utf-8",
}),
};
} catch (error) {
throw error;
}
};
export const saveSSHKey = async (
id: string,
publicKey: string,
privateKey: string,
) => {
const applicationDirectory = SSH_PATH;
const privateKeyPath = path.join(applicationDirectory, `${id}_rsa`);
const publicKeyPath = path.join(applicationDirectory, `${id}_rsa.pub`);
const privateKeyStream = fs.createWriteStream(privateKeyPath, {
mode: 0o600,
});
privateKeyStream.write(privateKey);
privateKeyStream.end();
fs.writeFileSync(publicKeyPath, publicKey);
};
export const generateSSHKey = async (type: "rsa" | "ed25519" = "rsa") => {
const applicationDirectory = SSH_PATH;
if (!fs.existsSync(applicationDirectory)) {
fs.mkdirSync(applicationDirectory, { recursive: true });
}
const keyPath = path.join(applicationDirectory, `${appName}_rsa`);
const keyPath = path.join(applicationDirectory, "temp_rsa");
if (fs.existsSync(`${keyPath}`)) {
fs.unlinkSync(`${keyPath}`);
}
if (fs.existsSync(`${keyPath}.pub`)) {
fs.unlinkSync(`${keyPath}.pub`);
}
const args = [
"-t",
"rsa",
type,
"-b",
"4096",
"-C",
"dokploy",
"-m",
"PEM",
"-f",
keyPath,
"-N",
"",
];
try {
await spawnAsync("ssh-keygen", args);
return keyPath;
} catch (error) {
throw error;
}
};
export const readRSAFile = async (appName: string) => {
try {
if (!fs.existsSync(SSH_PATH)) {
fs.mkdirSync(SSH_PATH, { recursive: true });
}
const keyPath = path.join(SSH_PATH, `${appName}_rsa.pub`);
const data = fs.readFileSync(keyPath, { encoding: "utf-8" });
const data = await readSSHKey("temp");
await removeSSHKey("temp");
return data;
} catch (error) {
throw error;
}
};
export const removeRSAFiles = async (appName: string) => {
export const removeSSHKey = async (id: string) => {
try {
const publicKeyPath = path.join(SSH_PATH, `${appName}_rsa.pub`);
const privateKeyPath = path.join(SSH_PATH, `${appName}_rsa`);
const publicKeyPath = path.join(SSH_PATH, `${id}_rsa.pub`);
const privateKeyPath = path.join(SSH_PATH, `${id}_rsa`);
await fs.promises.unlink(publicKeyPath);
await fs.promises.unlink(privateKeyPath);
} catch (error) {

View File

@@ -39,7 +39,7 @@ export const spawnAsync = (
if (code === 0) {
resolve(stdout);
} else {
const err = new Error(`child exited with code ${code}`) as Error & {
const err = new Error(`${stderr.toString()}`) as Error & {
code: number;
stderr: BufferList;
stdout: BufferList;

View File

@@ -1,5 +1,6 @@
import { createWriteStream } from "node:fs";
import path, { join } from "node:path";
import { updateSSHKeyById } from "@/server/api/services/ssh-key";
import { APPLICATIONS_PATH, COMPOSE_PATH, SSH_PATH } from "@/server/constants";
import { TRPCError } from "@trpc/server";
import { recreateDirectory } from "../filesystem/directory";
@@ -11,12 +12,12 @@ export const cloneGitRepository = async (
appName: string;
customGitUrl?: string | null;
customGitBranch?: string | null;
customGitSSHKey?: string | null;
customGitSSHKeyId?: string | null;
},
logPath: string,
isCompose = false,
) => {
const { appName, customGitUrl, customGitBranch, customGitSSHKey } = entity;
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
if (!customGitUrl || !customGitBranch) {
throw new TRPCError({
@@ -26,7 +27,7 @@ export const cloneGitRepository = async (
}
const writeStream = createWriteStream(logPath, { flags: "a" });
const keyPath = path.join(SSH_PATH, `${appName}_rsa`);
const keyPath = path.join(SSH_PATH, `${customGitSSHKeyId}_rsa`);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
@@ -40,6 +41,13 @@ export const cloneGitRepository = async (
`\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`,
);
if (customGitSSHKeyId) {
await updateSSHKeyById({
sshKeyId: customGitSSHKeyId,
lastUsedAt: new Date().toISOString(),
});
}
await spawnAsync(
"git",
[
@@ -60,12 +68,13 @@ export const cloneGitRepository = async (
{
env: {
...process.env,
...(customGitSSHKey && {
...(customGitSSHKeyId && {
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
},
);
writeStream.write(`\nCloned Custom Git ${customGitUrl}: ✅\n`);
} catch (error) {
writeStream.write(`\nERROR Cloning Custom Git: ${error}: ❌\n`);

View File

@@ -13,12 +13,27 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
const config: FileConfig = loadOrCreateConfig(appName);
const serviceName = `${appName}-service-${domain.uniqueConfigKey}`;
const routerName = `${appName}-router-${domain.uniqueConfigKey}`;
const routerNameSecure = `${appName}-router-websecure-${domain.uniqueConfigKey}`;
config.http = config.http || { routers: {}, services: {} };
config.http.routers = config.http.routers || {};
config.http.services = config.http.services || {};
config.http.routers[routerName] = await createRouterConfig(app, domain);
config.http.routers[routerName] = await createRouterConfig(
app,
domain,
"web",
);
if (domain.https) {
config.http.routers[routerNameSecure] = await createRouterConfig(
app,
domain,
"websecure",
);
} else {
delete config.http.routers[routerNameSecure];
}
config.http.services[serviceName] = createServiceConfig(appName, domain);
writeTraefikConfig(config, appName);
@@ -28,10 +43,15 @@ export const removeDomain = async (appName: string, uniqueKey: number) => {
const config: FileConfig = loadOrCreateConfig(appName);
const routerKey = `${appName}-router-${uniqueKey}`;
const routerSecureKey = `${appName}-router-websecure-${uniqueKey}`;
const serviceKey = `${appName}-service-${uniqueKey}`;
if (config.http?.routers?.[routerKey]) {
delete config.http.routers[routerKey];
}
if (config.http?.routers?.[routerSecureKey]) {
delete config.http.routers[routerSecureKey];
}
if (config.http?.services?.[serviceKey]) {
delete config.http.services[serviceKey];
}
@@ -47,7 +67,11 @@ export const removeDomain = async (appName: string, uniqueKey: number) => {
}
};
const createRouterConfig = async (app: ApplicationNested, domain: Domain) => {
export const createRouterConfig = async (
app: ApplicationNested,
domain: Domain,
entryPoint: "web" | "websecure",
) => {
const { appName, redirects, security } = app;
const { certificateType } = domain;
@@ -56,32 +80,33 @@ const createRouterConfig = async (app: ApplicationNested, domain: Domain) => {
rule: `Host(\`${host}\`)${path ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
middlewares: [],
entryPoints: https
? ["web", ...(process.env.NODE_ENV === "production" ? ["websecure"] : [])]
: ["web"],
tls: {},
entryPoints: [entryPoint],
};
if (https) {
if (entryPoint === "web" && https) {
routerConfig.middlewares = ["redirect-to-https"];
}
// redirects
for (const redirect of redirects) {
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
routerConfig.middlewares?.push(middlewareName);
if ((entryPoint === "websecure" && https) || !https) {
// redirects
for (const redirect of redirects) {
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
routerConfig.middlewares?.push(middlewareName);
}
// security
if (security.length > 0) {
const middlewareName = `auth-${appName}`;
routerConfig.middlewares?.push(middlewareName);
}
}
// security
if (security.length > 0) {
const middlewareName = `auth-${appName}`;
routerConfig.middlewares?.push(middlewareName);
}
if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "none") {
routerConfig.tls = undefined;
if (entryPoint === "websecure") {
if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "none") {
routerConfig.tls = undefined;
}
}
return routerConfig;

View File

@@ -10,10 +10,9 @@ import {
export const updateRedirectMiddleware = (appName: string, data: Redirect) => {
const config = loadMiddlewares<FileConfig>();
const middlewareName = `redirect-${appName}-${data.uniqueConfigKey}`;
if (config?.http?.middlewares?.[appName]) {
const middlewareName = `${appName}-${data.uniqueConfigKey}`;
if (config?.http?.middlewares?.[middlewareName]) {
config.http.middlewares[middlewareName] = {
redirectRegex: {
regex: data.regex,

View File

@@ -102,12 +102,6 @@
}
}
#terminal span {
font-family: "Inter", sans-serif;
font-weight: 500;
letter-spacing: 0px !important;
}
/* Codemirror */
.cm-editor {
@apply w-full h-full rounded-md overflow-hidden border border-solid border-border outline-none;

View File

@@ -1,18 +1,18 @@
version: "3.8"
services:
appsmith:
image: index.docker.io/appsmith/appsmith-ee:v1.29
networks:
- dokploy-network
ports:
- ${APP_SMITH_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${APP_SMITH_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${APP_SMITH_PORT}"
volumes:
- ./stacks:/appsmith-stacks
appsmith:
image: index.docker.io/appsmith/appsmith-ee:v1.29
networks:
- dokploy-network
ports:
- ${APP_SMITH_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${APP_SMITH_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${APP_SMITH_PORT}"
volumes:
- ../files/stacks:/appsmith-stacks
networks:
dokploy-network:
external: true
external: true

View File

@@ -23,8 +23,8 @@ services:
ports:
- 8055
volumes:
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
- ../files/uploads:/directus/uploads
- ../files/extensions:/directus/extensions
depends_on:
- cache
- database
@@ -53,4 +53,4 @@ networks:
dokploy-network:
external: true
volumes:
directus:
directus:

View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
jellyfin:
image: jellyfin/jellyfin:10
networks:
- dokploy-network
ports:
- ${JELLYFIN_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${JELLYFIN_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${JELLYFIN_PORT}"
volumes:
- config:/config
- cache:/cache
- media:/media
restart: 'unless-stopped'
# Optional - alternative address used for autodiscovery
environment:
- JELLYFIN_PublishedServerUrl=http://${JELLYFIN_HOST}
# Optional - may be necessary for docker healthcheck to pass if running in host network mode
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
config:
cache:
media:
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,22 @@
// EXAMPLE
import {
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const port = 8096;
const envs = [
`JELLYFIN_HOST=${randomDomain}`,
`HASH=${mainServiceHash}`,
`JELLYFIN_PORT=${port}`,
];
return {
envs,
};
}

View File

@@ -23,10 +23,15 @@ services:
networks:
- dokploy-network
volumes:
- ./config.toml:/listmonk/config.toml
- ../files/config.toml:/listmonk/config.toml
depends_on:
- db
command: [sh, -c, "sleep 3 && ./listmonk --install --idempotent --yes --config config.toml"]
command:
[
sh,
-c,
"sleep 3 && ./listmonk --install --idempotent --yes --config config.toml",
]
app:
restart: unless-stopped
@@ -41,7 +46,7 @@ services:
- db
- setup
volumes:
- ./config.toml:/listmonk/config.toml
- ../files/config.toml:/listmonk/config.toml
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${LISTMONK_HOST}`)"
@@ -50,7 +55,7 @@ services:
volumes:
listmonk-data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
web:
image: odoo:16.0
@@ -18,8 +18,8 @@ services:
- "traefik.http.services.${HASH}.loadbalancer.server.port=${ODOO_PORT}"
volumes:
- odoo-web-data:/var/lib/odoo
- ./config:/etc/odoo
- ./addons:/mnt/extra-addons
- ../files/config:/etc/odoo
- ../files/addons:/mnt/extra-addons
db:
image: postgres:13
@@ -36,7 +36,6 @@ volumes:
odoo-web-data:
odoo-db-data:
networks:
dokploy-network:
external: true

View File

@@ -18,8 +18,8 @@ services:
volumes:
- event-data:/var/lib/clickhouse
- event-logs:/var/log/clickhouse-server
- ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
- ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
- ../files/clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
- ../files/clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
ulimits:
nofile:
soft: 262144
@@ -50,7 +50,7 @@ volumes:
driver: local
event-logs:
driver: local
networks:
dokploy-network:
external: true
external: true

View File

@@ -378,4 +378,19 @@ export const templates: TemplateData[] = [
tags: ["analytics"],
load: () => import("./umami/index").then((m) => m.generate),
},
{
id: "jellyfin",
name: "jellyfin",
version: "v10.9.7",
description:
"Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. ",
logo: "jellyfin.svg",
links: {
github: "https://github.com/jellyfin/jellyfin",
website: "https://jellyfin.org/",
docs: "https://jellyfin.org/docs/",
},
tags: ["media system"],
load: () => import("./jellyfin/index").then((m) => m.generate),
},
];