mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bf88b90c3 | ||
|
|
947d2217df | ||
|
|
9eba4f8b84 | ||
|
|
f0d0e4c1e2 | ||
|
|
24cb47bcb1 | ||
|
|
9cf4a5e7a3 | ||
|
|
7b91d67655 | ||
|
|
ca0acf0445 | ||
|
|
576ff02773 | ||
|
|
d8b28107cd | ||
|
|
9898cac2f5 | ||
|
|
dd9bed4c2b | ||
|
|
14c8ae675a | ||
|
|
bda0689e18 | ||
|
|
7e39be4ca1 | ||
|
|
2e3a7c6164 | ||
|
|
f02f75e3a0 | ||
|
|
fe51dd6b0a | ||
|
|
2724336cad | ||
|
|
12bd017d07 | ||
|
|
a2eff67d44 | ||
|
|
71555a15f8 | ||
|
|
c681aa2e9f | ||
|
|
1f81ebd4fe | ||
|
|
d243470029 | ||
|
|
054e581023 | ||
|
|
f866250c25 | ||
|
|
ee58672d58 | ||
|
|
135894da2a | ||
|
|
06c8688ddd | ||
|
|
d8474b8aa3 | ||
|
|
9a4b474cdc | ||
|
|
1519a71535 | ||
|
|
9e47103131 | ||
|
|
ef689f06d6 | ||
|
|
54c7572447 | ||
|
|
4cacc6b3d1 | ||
|
|
8b193d4317 | ||
|
|
e72add74c3 | ||
|
|
115c8641e7 | ||
|
|
0af532f87e | ||
|
|
d1bd2b29fe | ||
|
|
734a6607c8 | ||
|
|
d3a2b03bb7 | ||
|
|
9a1436d0ae | ||
|
|
087e2c81cc | ||
|
|
32f35a6ca0 | ||
|
|
7fd35999b1 | ||
|
|
fa5b75e6fb | ||
|
|
7262e0debe | ||
|
|
13c686c228 | ||
|
|
083bb7b87d | ||
|
|
1b7ecd5a41 | ||
|
|
56b52b3f9c | ||
|
|
88f67b1c71 | ||
|
|
75bd10cbd5 | ||
|
|
d3397cfbd0 | ||
|
|
a4bf48fa68 | ||
|
|
dfe3294088 | ||
|
|
5b1ca4eafc | ||
|
|
82721251cc | ||
|
|
cd051b72fc | ||
|
|
411070cfcc | ||
|
|
8230c1ba91 | ||
|
|
8b7df6ce16 | ||
|
|
6d71eac221 | ||
|
|
fd3e4a8bc7 | ||
|
|
c13eb65b5a | ||
|
|
bb13a09def | ||
|
|
94bcea36c6 | ||
|
|
028b9b3f7b | ||
|
|
b85639f98e | ||
|
|
7c981b2aac | ||
|
|
59b072e7e0 | ||
|
|
6780fa9688 | ||
|
|
ff47a157c7 | ||
|
|
3601abc4c1 | ||
|
|
a15eb3b229 | ||
|
|
b3b7439617 | ||
|
|
e00cbaeb8a | ||
|
|
655e29d96b | ||
|
|
7240ff38f1 | ||
|
|
4a08bacba0 | ||
|
|
54aaa511d5 | ||
|
|
ad696ea54a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,6 +52,7 @@ yarn-error.log*
|
|||||||
# otros
|
# otros
|
||||||
/.data
|
/.data
|
||||||
/.main
|
/.main
|
||||||
|
.vscode
|
||||||
|
|
||||||
*.lockb
|
*.lockb
|
||||||
*.rdb
|
*.rdb
|
||||||
|
|||||||
187
__test__/traefik/traefik.test.ts
Normal file
187
__test__/traefik/traefik.test.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -28,84 +28,103 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { useEffect, useState } from "react";
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
|
import { domain } from "@/server/db/validations";
|
||||||
// .regex(hostnameRegex
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
const addDomain = z.object({
|
import type z from "zod";
|
||||||
host: z.string().min(1, "Hostname is required"),
|
|
||||||
path: z.string().min(1),
|
|
||||||
port: z.number(),
|
|
||||||
https: z.boolean(),
|
|
||||||
certificateType: z.enum(["letsencrypt", "none"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AddDomain = z.infer<typeof addDomain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
children?: React.ReactNode;
|
domainId?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddDomain = ({
|
export const AddDomain = ({
|
||||||
applicationId,
|
applicationId,
|
||||||
children = <PlusIcon className="h-4 w-4" />,
|
domainId = "",
|
||||||
|
children,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { data, refetch } = api.domain.one.useQuery(
|
||||||
const { mutateAsync, isError, error } = api.domain.create.useMutation();
|
{
|
||||||
|
domainId,
|
||||||
const form = useForm<AddDomain>({
|
|
||||||
defaultValues: {
|
|
||||||
host: "",
|
|
||||||
https: false,
|
|
||||||
path: "/",
|
|
||||||
port: 3000,
|
|
||||||
certificateType: "none",
|
|
||||||
},
|
},
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
if (data) {
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
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({
|
await mutateAsync({
|
||||||
|
domainId,
|
||||||
applicationId,
|
applicationId,
|
||||||
host: data.host,
|
...data,
|
||||||
https: data.https,
|
|
||||||
path: data.path,
|
|
||||||
port: data.port,
|
|
||||||
certificateType: data.certificateType,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Domain Created");
|
toast.success(dictionary.success);
|
||||||
await utils.domain.byApplicationId.invalidate({
|
await utils.domain.byApplicationId.invalidate({
|
||||||
applicationId,
|
applicationId,
|
||||||
});
|
});
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||||
|
|
||||||
|
if (domainId) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to create the domain");
|
toast.error(dictionary.error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<Button>{children}</Button>
|
{children}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Domain</DialogTitle>
|
<DialogTitle>Domain</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||||
In this section you can add custom domains
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
@@ -169,33 +188,36 @@ export const AddDomain = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
{form.getValues().https && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="certificateType"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="certificateType"
|
||||||
<FormItem className="col-span-2">
|
render={({ field }) => (
|
||||||
<FormLabel>Certificate</FormLabel>
|
<FormItem className="col-span-2">
|
||||||
<Select
|
<FormLabel>Certificate</FormLabel>
|
||||||
onValueChange={field.onChange}
|
<Select
|
||||||
defaultValue={field.value || ""}
|
onValueChange={field.onChange}
|
||||||
>
|
defaultValue={field.value || ""}
|
||||||
<FormControl>
|
>
|
||||||
<SelectTrigger>
|
<FormControl>
|
||||||
<SelectValue placeholder="Select a certificate" />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select a certificate" />
|
||||||
</FormControl>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="https"
|
name="https"
|
||||||
@@ -206,6 +228,7 @@ export const AddDomain = ({
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
Automatically provision SSL Certificate.
|
Automatically provision SSL Certificate.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -226,7 +249,7 @@ export const AddDomain = ({
|
|||||||
form="hook-form"
|
form="hook-form"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Create
|
{dictionary.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
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 Link from "next/link";
|
||||||
import React from "react";
|
|
||||||
import { AddDomain } from "./add-domain";
|
import { AddDomain } from "./add-domain";
|
||||||
import { DeleteDomain } from "./delete-domain";
|
import { DeleteDomain } from "./delete-domain";
|
||||||
import { GenerateDomain } from "./generate-domain";
|
import { GenerateDomain } from "./generate-domain";
|
||||||
import { UpdateDomain } from "./update-domain";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
@@ -43,7 +41,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain applicationId={applicationId}>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<Button>
|
||||||
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
|
</Button>
|
||||||
</AddDomain>
|
</AddDomain>
|
||||||
)}
|
)}
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
@@ -61,7 +61,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain applicationId={applicationId}>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<Button>
|
||||||
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
|
</Button>
|
||||||
</AddDomain>
|
</AddDomain>
|
||||||
|
|
||||||
<GenerateDomain applicationId={applicationId} />
|
<GenerateDomain applicationId={applicationId} />
|
||||||
@@ -90,7 +92,14 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
{item.https ? "HTTPS" : "HTTP"}
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-row gap-1">
|
<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} />
|
<DeleteDomain domainId={item.domainId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,30 +1,16 @@
|
|||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Card,
|
import { Form } from "@/components/ui/form";
|
||||||
CardContent,
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
environment: z.string(),
|
env: z.string(),
|
||||||
|
buildArgs: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -34,7 +20,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveEnvironment.useMutation();
|
api.application.saveEnvironment.useMutation();
|
||||||
|
|
||||||
@@ -46,24 +31,19 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
environment: "",
|
env: data?.env || "",
|
||||||
|
buildArgs: data?.buildArgs || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
environment: data.env || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form.reset, data, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: EnvironmentSchema) => {
|
const onSubmit = async (data: EnvironmentSchema) => {
|
||||||
mutateAsync({
|
mutateAsync({
|
||||||
env: data.environment,
|
env: data.env,
|
||||||
|
buildArgs: data.buildArgs,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -74,94 +54,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
toast.error("Error to add environment");
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<Form {...form}>
|
||||||
<Card className="bg-background">
|
<form
|
||||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<div>
|
className="flex w-full flex-col gap-5 "
|
||||||
<CardTitle className="text-xl">Environment Settings</CardTitle>
|
>
|
||||||
<CardDescription>
|
<Card className="bg-background">
|
||||||
You can add environment variables to your resource.
|
<Secrets
|
||||||
</CardDescription>
|
name="env"
|
||||||
</div>
|
title="Environment Settings"
|
||||||
|
description="You can add environment variables to your resource."
|
||||||
<Toggle
|
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||||
aria-label="Toggle bold"
|
/>
|
||||||
pressed={isEnvVisible}
|
{data?.buildType === "dockerfile" && (
|
||||||
onPressedChange={setIsEnvVisible}
|
<Secrets
|
||||||
>
|
name="buildArgs"
|
||||||
{isEnvVisible ? (
|
title="Build-time Variables"
|
||||||
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
|
description={
|
||||||
) : (
|
<span>
|
||||||
<EyeIcon className="h-4 w-4 text-muted-foreground" />
|
Available only at build-time. See documentation
|
||||||
)}
|
<a
|
||||||
</Toggle>
|
className="text-primary"
|
||||||
</CardHeader>
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
<CardContent>
|
target="_blank"
|
||||||
<Form {...form}>
|
rel="noopener noreferrer"
|
||||||
<form
|
>
|
||||||
id="hook-form"
|
here
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
</a>
|
||||||
className="w-full space-y-4"
|
.
|
||||||
>
|
</span>
|
||||||
<FormField
|
}
|
||||||
control={form.control}
|
placeholder="NPM_TOKEN=xyz"
|
||||||
name="environment"
|
/>
|
||||||
render={({ field }) => (
|
)}
|
||||||
<FormItem className="w-full">
|
<CardContent>
|
||||||
<FormControl>
|
<div className="flex flex-row justify-end">
|
||||||
<CodeEditor
|
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||||
language="properties"
|
Save
|
||||||
disabled={isEnvVisible}
|
</Button>
|
||||||
placeholder={`NODE_ENV=production
|
</div>
|
||||||
PORT=3000
|
</CardContent>
|
||||||
`}
|
</Card>
|
||||||
className="h-96 font-mono"
|
</form>
|
||||||
{...field}
|
</Form>
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -17,11 +8,20 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||||
import { CopyIcon, LockIcon } from "lucide-react";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,6 +33,7 @@ const GitProviderSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
buildPath: z.string().min(1, "Build Path required"),
|
buildPath: z.string().min(1, "Build Path required"),
|
||||||
|
sshKey: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -43,19 +44,18 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveGitProdiver.useMutation();
|
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>({
|
const form = useForm<GitProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
branch: "",
|
branch: "",
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repositoryURL: "",
|
repositoryURL: "",
|
||||||
|
sshKey: undefined,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -63,6 +63,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
|
sshKey: data.customGitSSHKeyId || undefined,
|
||||||
branch: data.customGitBranch || "",
|
branch: data.customGitBranch || "",
|
||||||
buildPath: data.customGitBuildPath || "/",
|
buildPath: data.customGitBuildPath || "/",
|
||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
@@ -75,6 +76,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
customGitBranch: values.branch,
|
customGitBranch: values.branch,
|
||||||
customGitBuildPath: values.buildPath,
|
customGitBuildPath: values.buildPath,
|
||||||
customGitUrl: values.repositoryURL,
|
customGitUrl: values.repositoryURL,
|
||||||
|
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -92,160 +94,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-2 gap-4 ">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2 space-y-4">
|
<div className="flex items-end col-span-2 gap-4">
|
||||||
<FormField
|
<div className="grow">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="repositoryURL"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="repositoryURL"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel className="flex flex-row justify-between">
|
<FormItem>
|
||||||
Repository URL
|
<FormLabel>Repository URL</FormLabel>
|
||||||
<div className="flex gap-2">
|
<FormControl>
|
||||||
<Dialog>
|
<Input placeholder="git@bitbucket.org" {...field} />
|
||||||
<DialogTrigger className="flex flex-row gap-2">
|
</FormControl>
|
||||||
<LockIcon className="size-4 text-muted-foreground" />?
|
<FormMessage />
|
||||||
</DialogTrigger>
|
</FormItem>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
)}
|
||||||
<DialogHeader>
|
/>
|
||||||
<DialogTitle>Private Repository</DialogTitle>
|
</div>
|
||||||
<DialogDescription>
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
If your repository is private is necessary to
|
<FormField
|
||||||
generate SSH Keys to add to your git provider.
|
control={form.control}
|
||||||
</DialogDescription>
|
name="sshKey"
|
||||||
</DialogHeader>
|
render={({ field }) => (
|
||||||
<div className="grid gap-4 py-4">
|
<FormItem className="basis-40">
|
||||||
<div className="relative">
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
<Textarea
|
SSH Key
|
||||||
placeholder="Please click on Generate SSH Key"
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
className="no-scrollbar h-64 text-muted-foreground"
|
</FormLabel>
|
||||||
disabled={!data?.customGitSSHKey}
|
<FormControl>
|
||||||
contentEditable={false}
|
<Select
|
||||||
value={
|
key={field.value}
|
||||||
data?.customGitSSHKey ||
|
onValueChange={field.onChange}
|
||||||
"Please click on Generate SSH Key"
|
defaultValue={field.value}
|
||||||
}
|
value={field.value}
|
||||||
/>
|
>
|
||||||
<button
|
<SelectTrigger>
|
||||||
type="button"
|
<SelectValue placeholder="Select a key" />
|
||||||
className="absolute right-2 top-2"
|
</SelectTrigger>
|
||||||
onClick={() => {
|
<SelectContent>
|
||||||
copy(
|
<SelectGroup>
|
||||||
data?.customGitSSHKey ||
|
{sshKeys?.map((sshKey) => (
|
||||||
"Generate a SSH Key",
|
<SelectItem
|
||||||
);
|
key={sshKey.sshKeyId}
|
||||||
toast.success("SSH Copied to clipboard");
|
value={sshKey.sshKeyId}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CopyIcon className="size-4" />
|
{sshKey.name}
|
||||||
</button>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
</div>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
|
</SelectGroup>
|
||||||
{data?.customGitSSHKey && (
|
</SelectContent>
|
||||||
<Button
|
</Select>
|
||||||
variant="destructive"
|
</FormControl>
|
||||||
isLoading={
|
</FormItem>
|
||||||
isGeneratingSSHKey || isRemovingSSHKey
|
)}
|
||||||
}
|
/>
|
||||||
className="max-sm:w-full"
|
) : (
|
||||||
onClick={async () => {
|
<Button
|
||||||
await removeSSHKey({
|
variant="secondary"
|
||||||
applicationId,
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
})
|
type="button"
|
||||||
.then(async () => {
|
>
|
||||||
toast.success("SSH Key Removed");
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
await refetch();
|
</Button>
|
||||||
})
|
)}
|
||||||
.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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||||
Save{" "}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -17,11 +8,19 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import { KeyRoundIcon, LockIcon } from "lucide-react";
|
||||||
import { CopyIcon, LockIcon } from "lucide-react";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -33,6 +32,7 @@ const GitProviderSchema = z.object({
|
|||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
|
sshKey: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -43,19 +43,17 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
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, 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>({
|
const form = useForm<GitProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
branch: "",
|
branch: "",
|
||||||
repositoryURL: "",
|
repositoryURL: "",
|
||||||
composePath: "./docker-compose.yml",
|
composePath: "./docker-compose.yml",
|
||||||
|
sshKey: undefined,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -63,6 +61,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
|
sshKey: data.customGitSSHKeyId || undefined,
|
||||||
branch: data.customGitBranch || "",
|
branch: data.customGitBranch || "",
|
||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
@@ -74,6 +73,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
customGitBranch: values.branch,
|
customGitBranch: values.branch,
|
||||||
customGitUrl: values.repositoryURL,
|
customGitUrl: values.repositoryURL,
|
||||||
|
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
@@ -94,123 +94,72 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-2 gap-4 ">
|
<div className="grid md:grid-cols-2 gap-4 ">
|
||||||
<div className="md:col-span-2 space-y-4">
|
<div className="flex items-end col-span-2 gap-4">
|
||||||
<FormField
|
<div className="grow">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="repositoryURL"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="repositoryURL"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel className="flex flex-row justify-between">
|
<FormItem>
|
||||||
Repository URL
|
<FormLabel className="flex flex-row justify-between">
|
||||||
<div className="flex gap-2">
|
Repository URL
|
||||||
<Dialog>
|
</FormLabel>
|
||||||
<DialogTrigger className="flex flex-row gap-2">
|
<FormControl>
|
||||||
<LockIcon className="size-4 text-muted-foreground" />?
|
<Input placeholder="git@bitbucket.org" {...field} />
|
||||||
</DialogTrigger>
|
</FormControl>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<FormMessage />
|
||||||
<DialogHeader>
|
</FormItem>
|
||||||
<DialogTitle>Private Repository</DialogTitle>
|
)}
|
||||||
<DialogDescription>
|
/>
|
||||||
If your repository is private is necessary to
|
</div>
|
||||||
generate SSH Keys to add to your git provider.
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
</DialogDescription>
|
<FormField
|
||||||
</DialogHeader>
|
control={form.control}
|
||||||
<div className="grid gap-4 py-4">
|
name="sshKey"
|
||||||
<div className="relative">
|
render={({ field }) => (
|
||||||
<Textarea
|
<FormItem className="basis-40">
|
||||||
placeholder="Please click on Generate SSH Key"
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
className="no-scrollbar h-64 text-muted-foreground"
|
SSH Key
|
||||||
disabled={!data?.customGitSSHKey}
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
contentEditable={false}
|
</FormLabel>
|
||||||
value={
|
<FormControl>
|
||||||
data?.customGitSSHKey ||
|
<Select
|
||||||
"Please click on Generate SSH Key"
|
key={field.value}
|
||||||
}
|
onValueChange={field.onChange}
|
||||||
/>
|
defaultValue={field.value}
|
||||||
<button
|
value={field.value}
|
||||||
type="button"
|
>
|
||||||
className="absolute right-2 top-2"
|
<SelectTrigger>
|
||||||
onClick={() => {
|
<SelectValue placeholder="Select a key" />
|
||||||
copy(
|
</SelectTrigger>
|
||||||
data?.customGitSSHKey ||
|
<SelectContent>
|
||||||
"Generate a SSH Key",
|
<SelectGroup>
|
||||||
);
|
{sshKeys?.map((sshKey) => (
|
||||||
toast.success("SSH Copied to clipboard");
|
<SelectItem
|
||||||
}}
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
>
|
>
|
||||||
<CopyIcon className="size-4" />
|
{sshKey.name}
|
||||||
</button>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
</div>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
|
</SelectGroup>
|
||||||
{data?.customGitSSHKey && (
|
</SelectContent>
|
||||||
<Button
|
</Select>
|
||||||
variant="destructive"
|
</FormControl>
|
||||||
isLoading={
|
</FormItem>
|
||||||
isGeneratingSSHKey || isRemovingSSHKey
|
)}
|
||||||
}
|
/>
|
||||||
className="max-sm:w-full"
|
) : (
|
||||||
onClick={async () => {
|
<Button
|
||||||
await removeSSHKey({
|
variant="secondary"
|
||||||
composeId,
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
})
|
type="button"
|
||||||
.then(async () => {
|
>
|
||||||
toast.success("SSH Key Removed");
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
await refetch();
|
</Button>
|
||||||
})
|
)}
|
||||||
.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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ export const DockerLogs = dynamic(
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
appType: "stack" | "docker-compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDockerLogsCompose = ({ appName }: Props) => {
|
export const ShowDockerLogsCompose = ({ appName, appType }: Props) => {
|
||||||
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
|
appType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!appName,
|
enabled: !!appName,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ShowMonitoringCompose = ({
|
|||||||
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
appName: appName,
|
appName: appName,
|
||||||
|
appType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!appName,
|
enabled: !!appName,
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ export const DockerLogsId: React.FC<Props> = ({ id, containerId }) => {
|
|||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 30,
|
rows: 30,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.25,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
form,
|
form,
|
||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
|
ip,
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
form,
|
form,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
|
ip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
form,
|
form,
|
||||||
|
ip,
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
form,
|
form,
|
||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
|
ip,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form]);
|
}, [data?.appName, data?.externalPort, data?.databasePassword, form, ip]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
|
|||||||
200
components/dashboard/settings/ssh-keys/add-ssh-key.tsx
Normal file
200
components/dashboard/settings/ssh-keys/add-ssh-key.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
components/dashboard/settings/ssh-keys/delete-ssh-key.tsx
Normal file
61
components/dashboard/settings/ssh-keys/delete-ssh-key.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
Normal file
96
components/dashboard/settings/ssh-keys/show-ssh-keys.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
168
components/dashboard/settings/ssh-keys/update-ssh-key.tsx
Normal file
168
components/dashboard/settings/ssh-keys/update-ssh-key.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -37,6 +37,9 @@ export const WebServer = () => {
|
|||||||
api.settings.reloadTraefik.useMutation();
|
api.settings.reloadTraefik.useMutation();
|
||||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||||
api.settings.cleanAll.useMutation();
|
api.settings.cleanAll.useMutation();
|
||||||
|
const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } =
|
||||||
|
api.settings.toggleDashboard.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: cleanDockerBuilder,
|
mutateAsync: cleanDockerBuilder,
|
||||||
isLoading: cleanDockerBuilderIsLoading,
|
isLoading: cleanDockerBuilderIsLoading,
|
||||||
@@ -107,6 +110,7 @@ export const WebServer = () => {
|
|||||||
<span>View Traefik config</span>
|
<span>View Traefik config</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowServerTraefikConfig>
|
</ShowServerTraefikConfig>
|
||||||
|
|
||||||
<ShowServerMiddlewareConfig>
|
<ShowServerMiddlewareConfig>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
@@ -124,8 +128,14 @@ export const WebServer = () => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild disabled={reloadTraefikIsLoading}>
|
<DropdownMenuTrigger
|
||||||
<Button isLoading={reloadTraefikIsLoading} variant="outline">
|
asChild
|
||||||
|
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
Traefik
|
Traefik
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -157,6 +167,38 @@ export const WebServer = () => {
|
|||||||
<span>View Traefik config</span>
|
<span>View Traefik config</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</ShowMainTraefikConfig>
|
</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">
|
<DockerTerminalModal appName="dokploy-traefik">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
href: "/dashboard/settings/certificates",
|
href: "/dashboard/settings/certificates",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "SSH Keys",
|
||||||
|
label: "",
|
||||||
|
icon: KeyRound,
|
||||||
|
href: "/dashboard/settings/ssh-keys",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
label: "",
|
label: "",
|
||||||
@@ -86,6 +92,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
Database,
|
Database,
|
||||||
|
KeyRound,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Route,
|
Route,
|
||||||
Server,
|
Server,
|
||||||
|
|||||||
78
components/ui/secrets.tsx
Normal file
78
components/ui/secrets.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,7 +57,7 @@ echo "Network created"
|
|||||||
|
|
||||||
mkdir -p /etc/dokploy
|
mkdir -p /etc/dokploy
|
||||||
|
|
||||||
chmod -R 777 /etc/dokploy
|
chmod 777 /etc/dokploy
|
||||||
|
|
||||||
docker pull dokploy/dokploy:canary
|
docker pull dokploy/dokploy:canary
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ if ss -tulnp | grep ':443 ' >/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
command_exists() {
|
command_exists() {
|
||||||
command -v "$@" > /dev/null 2>&1
|
command -v "$@" > /dev/null 2>&1
|
||||||
}
|
}
|
||||||
@@ -46,7 +41,25 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
docker swarm leave --force 2>/dev/null
|
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"
|
echo "Swarm initialized"
|
||||||
|
|
||||||
@@ -57,7 +70,7 @@ echo "Network created"
|
|||||||
|
|
||||||
mkdir -p /etc/dokploy
|
mkdir -p /etc/dokploy
|
||||||
|
|
||||||
chmod -R 777 /etc/dokploy
|
chmod 777 /etc/dokploy
|
||||||
|
|
||||||
docker pull dokploy/dokploy:latest
|
docker pull dokploy/dokploy:latest
|
||||||
|
|
||||||
@@ -71,19 +84,28 @@ docker service create \
|
|||||||
--publish published=3000,target=3000,mode=host \
|
--publish published=3000,target=3000,mode=host \
|
||||||
--update-parallelism 1 \
|
--update-parallelism 1 \
|
||||||
--update-order stop-first \
|
--update-order stop-first \
|
||||||
|
--constraint 'node.role == manager' \
|
||||||
dokploy/dokploy:latest
|
dokploy/dokploy:latest
|
||||||
|
|
||||||
|
|
||||||
public_ip=$(hostname -I | awk '{print $1}')
|
|
||||||
|
|
||||||
GREEN="\033[0;32m"
|
GREEN="\033[0;32m"
|
||||||
YELLOW="\033[1;33m"
|
YELLOW="\033[1;33m"
|
||||||
BLUE="\033[0;34m"
|
BLUE="\033[0;34m"
|
||||||
NC="\033[0m" # No Color
|
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 ""
|
echo ""
|
||||||
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
|
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
|
||||||
printf "${BLUE}Wait 15 seconds for the server to start${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 ""
|
echo ""
|
||||||
|
|||||||
1
drizzle/0025_lying_mephisto.sql
Normal file
1
drizzle/0025_lying_mephisto.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "buildArgs" text;
|
||||||
25
drizzle/0026_known_dormammu.sql
Normal file
25
drizzle/0026_known_dormammu.sql
Normal 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";
|
||||||
2938
drizzle/meta/0025_snapshot.json
Normal file
2938
drizzle/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3010
drizzle/meta/0026_snapshot.json
Normal file
3010
drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,20 @@
|
|||||||
"when": 1721603595092,
|
"when": 1721603595092,
|
||||||
"tag": "0024_dapper_supernaut",
|
"tag": "0024_dapper_supernaut",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.4.0",
|
"version": "v0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"migration:run": "tsx -r dotenv/config migration.ts",
|
"migration:run": "tsx -r dotenv/config migration.ts",
|
||||||
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
|
"migration:up": "drizzle-kit up --config ./server/db/drizzle.config.ts",
|
||||||
"migration:drop": "drizzle-kit drop --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:truncate": "tsx -r dotenv/config ./server/db/reset.ts",
|
||||||
"db:studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
"db:studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||||
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ const Service = (
|
|||||||
|
|
||||||
<TabsContent value="logs">
|
<TabsContent value="logs">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowDockerLogsCompose appName={data?.appName || ""} />
|
<ShowDockerLogsCompose
|
||||||
|
appName={data?.appName || ""}
|
||||||
|
appType={data?.composeType || "docker-compose"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
41
pages/dashboard/settings/ssh-keys.tsx
Normal file
41
pages/dashboard/settings/ssh-keys.tsx
Normal 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: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
34
public/templates/jellyfin.svg
Normal file
34
public/templates/jellyfin.svg
Normal 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 |
@@ -23,6 +23,7 @@ import { redisRouter } from "./routers/redis";
|
|||||||
import { registryRouter } from "./routers/registry";
|
import { registryRouter } from "./routers/registry";
|
||||||
import { securityRouter } from "./routers/security";
|
import { securityRouter } from "./routers/security";
|
||||||
import { settingsRouter } from "./routers/settings";
|
import { settingsRouter } from "./routers/settings";
|
||||||
|
import { sshRouter } from "./routers/ssh-key";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
registry: registryRouter,
|
registry: registryRouter,
|
||||||
cluster: clusterRouter,
|
cluster: clusterRouter,
|
||||||
notification: notificationRouter,
|
notification: notificationRouter,
|
||||||
|
sshKey: sshRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ import {
|
|||||||
removeDirectoryCode,
|
removeDirectoryCode,
|
||||||
removeMonitoringDirectory,
|
removeMonitoringDirectory,
|
||||||
} from "@/server/utils/filesystem/directory";
|
} from "@/server/utils/filesystem/directory";
|
||||||
import {
|
|
||||||
generateSSHKey,
|
|
||||||
readRSAFile,
|
|
||||||
removeRSAFiles,
|
|
||||||
} from "@/server/utils/filesystem/ssh";
|
|
||||||
import {
|
import {
|
||||||
readConfig,
|
readConfig,
|
||||||
removeTraefikConfig,
|
removeTraefikConfig,
|
||||||
@@ -130,7 +125,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
async () => await removeMonitoringDirectory(application?.appName),
|
async () => await removeMonitoringDirectory(application?.appName),
|
||||||
async () => await removeTraefikConfig(application?.appName),
|
async () => await removeTraefikConfig(application?.appName),
|
||||||
async () => await removeService(application?.appName),
|
async () => await removeService(application?.appName),
|
||||||
async () => await removeRSAFiles(application?.appName),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const operation of cleanupOperations) {
|
for (const operation of cleanupOperations) {
|
||||||
@@ -187,6 +181,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await updateApplication(input.applicationId, {
|
await updateApplication(input.applicationId, {
|
||||||
env: input.env,
|
env: input.env,
|
||||||
|
buildArgs: input.buildArgs,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
@@ -234,36 +229,11 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
customGitBranch: input.customGitBranch,
|
customGitBranch: input.customGitBranch,
|
||||||
customGitBuildPath: input.customGitBuildPath,
|
customGitBuildPath: input.customGitBuildPath,
|
||||||
customGitUrl: input.customGitUrl,
|
customGitUrl: input.customGitUrl,
|
||||||
|
customGitSSHKeyId: input.customGitSSHKeyId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
applicationStatus: "idle",
|
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;
|
return true;
|
||||||
}),
|
}),
|
||||||
markRunning: protectedProcedure
|
markRunning: protectedProcedure
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ import { myQueue } from "@/server/queues/queueSetup";
|
|||||||
import { createCommand } from "@/server/utils/builders/compose";
|
import { createCommand } from "@/server/utils/builders/compose";
|
||||||
import { randomizeComposeFile } from "@/server/utils/docker/compose";
|
import { randomizeComposeFile } from "@/server/utils/docker/compose";
|
||||||
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
|
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
|
||||||
import {
|
|
||||||
generateSSHKey,
|
|
||||||
readRSAFile,
|
|
||||||
removeRSAFiles,
|
|
||||||
} from "@/server/utils/filesystem/ssh";
|
|
||||||
import { templates } from "@/templates/templates";
|
import { templates } from "@/templates/templates";
|
||||||
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
|
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
|
||||||
import {
|
import {
|
||||||
@@ -102,7 +97,6 @@ export const composeRouter = createTRPCRouter({
|
|||||||
async () => await removeCompose(composeResult),
|
async () => await removeCompose(composeResult),
|
||||||
async () => await removeDeploymentsByComposeId(composeResult),
|
async () => await removeDeploymentsByComposeId(composeResult),
|
||||||
async () => await removeComposeDirectory(composeResult.appName),
|
async () => await removeComposeDirectory(composeResult.appName),
|
||||||
async () => await removeRSAFiles(composeResult.appName),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const operation of cleanupOperations) {
|
for (const operation of cleanupOperations) {
|
||||||
@@ -181,38 +175,12 @@ export const composeRouter = createTRPCRouter({
|
|||||||
const command = createCommand(compose);
|
const command = createCommand(compose);
|
||||||
return `docker ${command}`;
|
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
|
refreshToken: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await updateCompose(input.composeId, {
|
await updateCompose(input.composeId, {
|
||||||
refreshToken: nanoid(),
|
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;
|
return true;
|
||||||
}),
|
}),
|
||||||
deployTemplate: protectedProcedure
|
deployTemplate: protectedProcedure
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
getContainersByAppNameMatch: protectedProcedure
|
getContainersByAppNameMatch: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
appType: z
|
||||||
|
.union([z.literal("stack"), z.literal("docker-compose")])
|
||||||
|
.optional(),
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await getContainersByAppNameMatch(input.appName);
|
return await getContainersByAppNameMatch(input.appName, input.appType);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getContainersByAppLabel: protectedProcedure
|
getContainersByAppLabel: protectedProcedure
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { MAIN_TRAEFIK_PATH, MONITORING_PATH, docker } from "@/server/constants";
|
import { MAIN_TRAEFIK_PATH, MONITORING_PATH, docker } from "@/server/constants";
|
||||||
import {
|
import {
|
||||||
apiAssignDomain,
|
apiAssignDomain,
|
||||||
|
apiEnableDashboard,
|
||||||
apiModifyTraefikConfig,
|
apiModifyTraefikConfig,
|
||||||
apiReadTraefikConfig,
|
apiReadTraefikConfig,
|
||||||
apiSaveSSHKey,
|
apiSaveSSHKey,
|
||||||
apiTraefikConfig,
|
apiTraefikConfig,
|
||||||
apiUpdateDockerCleanup,
|
apiUpdateDockerCleanup,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { initializeTraefik } from "@/server/setup/traefik-setup";
|
||||||
import {
|
import {
|
||||||
cleanStoppedContainers,
|
cleanStoppedContainers,
|
||||||
cleanUpDockerBuilder,
|
cleanUpDockerBuilder,
|
||||||
@@ -67,6 +69,13 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
toggleDashboard: adminProcedure
|
||||||
|
.input(apiEnableDashboard)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await initializeTraefik(input.enableDashboard);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
cleanUnusedImages: adminProcedure.mutation(async () => {
|
cleanUnusedImages: adminProcedure.mutation(async () => {
|
||||||
await cleanUpUnusedImages();
|
await cleanUpUnusedImages();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
70
server/api/routers/ssh-key.ts
Normal file
70
server/api/routers/ssh-key.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -66,10 +66,18 @@ export const getConfig = async (containerId: string) => {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getContainersByAppNameMatch = async (appName: string) => {
|
export const getContainersByAppNameMatch = async (
|
||||||
|
appName: string,
|
||||||
|
appType?: "stack" | "docker-compose",
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const cmd =
|
||||||
|
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'";
|
||||||
|
|
||||||
const { stdout, stderr } = await execAsync(
|
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) {
|
if (stderr) {
|
||||||
|
|||||||
78
server/api/services/ssh-key.ts
Normal file
78
server/api/services/ssh-key.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -93,3 +93,7 @@ export const apiModifyTraefikConfig = z.object({
|
|||||||
export const apiReadTraefikConfig = z.object({
|
export const apiReadTraefikConfig = z.object({
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiEnableDashboard = z.object({
|
||||||
|
enableDashboard: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { redirects } from "./redirects";
|
|||||||
import { registry } from "./registry";
|
import { registry } from "./registry";
|
||||||
import { security } from "./security";
|
import { security } from "./security";
|
||||||
import { applicationStatus } from "./shared";
|
import { applicationStatus } from "./shared";
|
||||||
|
import { sshKeys } from "./ssh-key";
|
||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
|
|
||||||
export const sourceType = pgEnum("sourceType", [
|
export const sourceType = pgEnum("sourceType", [
|
||||||
@@ -107,6 +108,7 @@ export const applications = pgTable("application", {
|
|||||||
.unique(),
|
.unique(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
|
buildArgs: text("buildArgs"),
|
||||||
memoryReservation: integer("memoryReservation"),
|
memoryReservation: integer("memoryReservation"),
|
||||||
memoryLimit: integer("memoryLimit"),
|
memoryLimit: integer("memoryLimit"),
|
||||||
cpuReservation: integer("cpuReservation"),
|
cpuReservation: integer("cpuReservation"),
|
||||||
@@ -131,7 +133,12 @@ export const applications = pgTable("application", {
|
|||||||
customGitUrl: text("customGitUrl"),
|
customGitUrl: text("customGitUrl"),
|
||||||
customGitBranch: text("customGitBranch"),
|
customGitBranch: text("customGitBranch"),
|
||||||
customGitBuildPath: text("customGitBuildPath"),
|
customGitBuildPath: text("customGitBuildPath"),
|
||||||
customGitSSHKey: text("customGitSSHKey"),
|
customGitSSHKeyId: text("customGitSSHKeyId").references(
|
||||||
|
() => sshKeys.sshKeyId,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
dockerfile: text("dockerfile"),
|
dockerfile: text("dockerfile"),
|
||||||
// Drop
|
// Drop
|
||||||
dropBuildPath: text("dropBuildPath"),
|
dropBuildPath: text("dropBuildPath"),
|
||||||
@@ -169,6 +176,10 @@ export const applicationsRelations = relations(
|
|||||||
references: [projects.projectId],
|
references: [projects.projectId],
|
||||||
}),
|
}),
|
||||||
deployments: many(deployments),
|
deployments: many(deployments),
|
||||||
|
customGitSSHKey: one(sshKeys, {
|
||||||
|
fields: [applications.customGitSSHKeyId],
|
||||||
|
references: [sshKeys.sshKeyId],
|
||||||
|
}),
|
||||||
domains: many(domains),
|
domains: many(domains),
|
||||||
mounts: many(mounts),
|
mounts: many(mounts),
|
||||||
redirects: many(redirects),
|
redirects: many(redirects),
|
||||||
@@ -275,6 +286,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
applicationId: z.string(),
|
applicationId: z.string(),
|
||||||
autoDeploy: z.boolean(),
|
autoDeploy: z.boolean(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
|
buildArgs: z.string().optional(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
memoryReservation: z.number().optional(),
|
memoryReservation: z.number().optional(),
|
||||||
@@ -287,7 +299,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
dockerImage: z.string().optional(),
|
dockerImage: z.string().optional(),
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
customGitSSHKey: z.string().optional(),
|
customGitSSHKeyId: z.string().optional(),
|
||||||
repository: z.string().optional(),
|
repository: z.string().optional(),
|
||||||
dockerfile: z.string().optional(),
|
dockerfile: z.string().optional(),
|
||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
@@ -369,12 +381,18 @@ export const apiSaveGitProvider = createSchema
|
|||||||
customGitBuildPath: true,
|
customGitBuildPath: true,
|
||||||
customGitUrl: true,
|
customGitUrl: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.merge(
|
||||||
|
createSchema.pick({
|
||||||
|
customGitSSHKeyId: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const apiSaveEnvironmentVariables = createSchema
|
export const apiSaveEnvironmentVariables = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
env: true,
|
env: true,
|
||||||
|
buildArgs: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sshKeys } from "@/server/db/schema/ssh-key";
|
||||||
import { generatePassword } from "@/templates/utils";
|
import { generatePassword } from "@/templates/utils";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
@@ -41,7 +42,12 @@ export const compose = pgTable("compose", {
|
|||||||
// Git
|
// Git
|
||||||
customGitUrl: text("customGitUrl"),
|
customGitUrl: text("customGitUrl"),
|
||||||
customGitBranch: text("customGitBranch"),
|
customGitBranch: text("customGitBranch"),
|
||||||
customGitSSHKey: text("customGitSSHKey"),
|
customGitSSHKeyId: text("customGitSSHKeyId").references(
|
||||||
|
() => sshKeys.sshKeyId,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
//
|
//
|
||||||
command: text("command").notNull().default(""),
|
command: text("command").notNull().default(""),
|
||||||
//
|
//
|
||||||
@@ -62,6 +68,10 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
deployments: many(deployments),
|
deployments: many(deployments),
|
||||||
mounts: many(mounts),
|
mounts: many(mounts),
|
||||||
|
customGitSSHKey: one(sshKeys, {
|
||||||
|
fields: [compose.customGitSSHKeyId],
|
||||||
|
references: [sshKeys.sshKeyId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
@@ -70,6 +80,7 @@ const createSchema = createInsertSchema(compose, {
|
|||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
composeFile: z.string().min(1),
|
composeFile: z.string().min(1),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
|
customGitSSHKeyId: z.string().optional(),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { domain } from "@/server/db/validations";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
|
import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
|
||||||
import { applications } from "./application";
|
import { applications } from "./application";
|
||||||
import { certificateType } from "./shared";
|
import { certificateType } from "./shared";
|
||||||
|
|
||||||
@@ -31,27 +31,17 @@ export const domainsRelations = relations(domains, ({ one }) => ({
|
|||||||
references: [applications.applicationId],
|
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
|
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
|
||||||
.pick({
|
|
||||||
host: true,
|
export const apiCreateDomain = createSchema.pick({
|
||||||
path: true,
|
host: true,
|
||||||
port: true,
|
path: true,
|
||||||
https: true,
|
port: true,
|
||||||
applicationId: true,
|
https: true,
|
||||||
certificateType: true,
|
applicationId: true,
|
||||||
})
|
certificateType: true,
|
||||||
.required();
|
});
|
||||||
|
|
||||||
export const apiFindDomain = createSchema
|
export const apiFindDomain = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
@@ -59,19 +49,16 @@ export const apiFindDomain = createSchema
|
|||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
export const apiFindDomainByApplication = createSchema
|
export const apiFindDomainByApplication = createSchema.pick({
|
||||||
.pick({
|
applicationId: true,
|
||||||
applicationId: true,
|
});
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiUpdateDomain = createSchema
|
export const apiUpdateDomain = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
domainId: true,
|
|
||||||
host: true,
|
host: true,
|
||||||
path: true,
|
path: true,
|
||||||
port: true,
|
port: true,
|
||||||
https: true,
|
https: true,
|
||||||
certificateType: true,
|
certificateType: true,
|
||||||
})
|
})
|
||||||
.required();
|
.merge(createSchema.pick({ domainId: true }).required());
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export * from "./shared";
|
|||||||
export * from "./compose";
|
export * from "./compose";
|
||||||
export * from "./registry";
|
export * from "./registry";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./ssh-key";
|
||||||
|
|||||||
69
server/db/schema/ssh-key.ts
Normal file
69
server/db/schema/ssh-key.ts
Normal 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(),
|
||||||
|
);
|
||||||
59
server/db/validations/index.ts
Normal file
59
server/db/validations/index.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync, mkdirSync } from "node:fs";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
APPLICATIONS_PATH,
|
APPLICATIONS_PATH,
|
||||||
BASE_PATH,
|
BASE_PATH,
|
||||||
@@ -32,6 +33,9 @@ export const setupDirectories = () => {
|
|||||||
for (const dir of directories) {
|
for (const dir of directories) {
|
||||||
try {
|
try {
|
||||||
createDirectoryIfNotExist(dir);
|
createDirectoryIfNotExist(dir);
|
||||||
|
if (dir === SSH_PATH) {
|
||||||
|
chmodSync(SSH_PATH, "700");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error, " On path: ", dir);
|
console.log(error, " On path: ", dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 path from "node:path";
|
||||||
import type { CreateServiceOptions } from "dockerode";
|
import type { CreateServiceOptions } from "dockerode";
|
||||||
import { dump } from "js-yaml";
|
import { dump } from "js-yaml";
|
||||||
@@ -11,7 +11,7 @@ const TRAEFIK_SSL_PORT =
|
|||||||
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
|
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
|
||||||
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT ?? "", 10) || 80;
|
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 imageName = "traefik:v2.5";
|
||||||
const containerName = "dokploy-traefik";
|
const containerName = "dokploy-traefik";
|
||||||
const settings: CreateServiceOptions = {
|
const settings: CreateServiceOptions = {
|
||||||
@@ -59,11 +59,15 @@ export const initializeTraefik = async () => {
|
|||||||
PublishedPort: TRAEFIK_PORT,
|
PublishedPort: TRAEFIK_PORT,
|
||||||
PublishMode: "host",
|
PublishMode: "host",
|
||||||
},
|
},
|
||||||
{
|
...(enableDashboard
|
||||||
TargetPort: 8080,
|
? [
|
||||||
PublishedPort: 8080,
|
{
|
||||||
PublishMode: "host",
|
TargetPort: 8080,
|
||||||
},
|
PublishedPort: 8080,
|
||||||
|
PublishMode: "host" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -86,6 +90,7 @@ export const initializeTraefik = async () => {
|
|||||||
|
|
||||||
export const createDefaultServerTraefikConfig = () => {
|
export const createDefaultServerTraefikConfig = () => {
|
||||||
const configFilePath = path.join(DYNAMIC_TRAEFIK_PATH, "dokploy.yml");
|
const configFilePath = path.join(DYNAMIC_TRAEFIK_PATH, "dokploy.yml");
|
||||||
|
|
||||||
if (existsSync(configFilePath)) {
|
if (existsSync(configFilePath)) {
|
||||||
console.log("Default traefik config already exists");
|
console.log("Default traefik config already exists");
|
||||||
return;
|
return;
|
||||||
@@ -125,6 +130,11 @@ export const createDefaultServerTraefikConfig = () => {
|
|||||||
|
|
||||||
export const createDefaultTraefikConfig = () => {
|
export const createDefaultTraefikConfig = () => {
|
||||||
const mainConfig = path.join(MAIN_TRAEFIK_PATH, "traefik.yml");
|
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)) {
|
if (existsSync(mainConfig)) {
|
||||||
console.log("Main config already exists");
|
console.log("Main config already exists");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Compose Type: ${composeType} ✅`;
|
|||||||
|
|
||||||
writeStream.write("Docker Compose Deployed: ✅");
|
writeStream.write("Docker Compose Deployed: ✅");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeStream.write(`ERROR: ${error}: ❌`);
|
writeStream.write("Error ❌");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
import type { WriteStream } from "node:fs";
|
import type { WriteStream } from "node:fs";
|
||||||
import { docker } from "@/server/constants";
|
import { prepareEnvironmentVariables } from "@/server/utils/docker/utils";
|
||||||
import * as tar from "tar-fs";
|
|
||||||
import type { ApplicationNested } from ".";
|
import type { ApplicationNested } from ".";
|
||||||
import { getBuildAppDirectory } from "../filesystem/directory";
|
import { getBuildAppDirectory } from "../filesystem/directory";
|
||||||
|
import { spawnAsync } from "../process/spawnAsync";
|
||||||
import { createEnvFile } from "./utils";
|
import { createEnvFile } from "./utils";
|
||||||
|
|
||||||
export const buildCustomDocker = async (
|
export const buildCustomDocker = async (
|
||||||
application: ApplicationNested,
|
application: ApplicationNested,
|
||||||
writeStream: WriteStream,
|
writeStream: WriteStream,
|
||||||
) => {
|
) => {
|
||||||
const { appName, env } = application;
|
const { appName, env, buildArgs } = application;
|
||||||
const dockerFilePath = getBuildAppDirectory(application);
|
const dockerFilePath = getBuildAppDirectory(application);
|
||||||
try {
|
try {
|
||||||
const image = `${appName}`;
|
const image = `${appName}`;
|
||||||
|
|
||||||
const contextPath =
|
const contextPath =
|
||||||
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
|
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);
|
createEnvFile(dockerFilePath, env);
|
||||||
|
await spawnAsync(
|
||||||
const stream = await docker.buildImage(tarStream, {
|
"docker",
|
||||||
t: image,
|
commandArgs,
|
||||||
dockerfile: dockerFilePath.substring(dockerFilePath.lastIndexOf("/") + 1),
|
(data) => {
|
||||||
});
|
if (writeStream.writable) {
|
||||||
|
writeStream.write(data);
|
||||||
await new Promise((resolve, reject) => {
|
}
|
||||||
docker.modem.followProgress(
|
},
|
||||||
stream,
|
{
|
||||||
(err, res) => (err ? reject(err) : resolve(res)),
|
cwd: contextPath,
|
||||||
(event) => {
|
},
|
||||||
if (event.stream) {
|
);
|
||||||
writeStream.write(event.stream);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const buildApplication = async (
|
|||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
writeStream.write("Docker Deployed: ✅");
|
writeStream.write("Docker Deployed: ✅");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeStream.write(`ERROR: ${error}: ❌`);
|
writeStream.write("Error ❌");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
|
|||||||
@@ -161,6 +161,21 @@ export const removeService = async (appName: string) => {
|
|||||||
export const prepareEnvironmentVariables = (env: string | null) =>
|
export const prepareEnvironmentVariables = (env: string | null) =>
|
||||||
Object.entries(parse(env ?? "")).map(([key, value]) => `${key}=${value}`);
|
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"]) => {
|
export const generateVolumeMounts = (mounts: ApplicationNested["mounts"]) => {
|
||||||
if (!mounts || mounts.length === 0) {
|
if (!mounts || mounts.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -3,57 +3,90 @@ import * as path from "node:path";
|
|||||||
import { SSH_PATH } from "@/server/constants";
|
import { SSH_PATH } from "@/server/constants";
|
||||||
import { spawnAsync } from "../process/spawnAsync";
|
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;
|
const applicationDirectory = SSH_PATH;
|
||||||
|
|
||||||
if (!fs.existsSync(applicationDirectory)) {
|
if (!fs.existsSync(applicationDirectory)) {
|
||||||
fs.mkdirSync(applicationDirectory, { recursive: true });
|
fs.mkdirSync(applicationDirectory, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyPath = path.join(applicationDirectory, `${appName}_rsa`);
|
const keyPath = path.join(applicationDirectory, "temp_rsa");
|
||||||
|
|
||||||
if (fs.existsSync(`${keyPath}`)) {
|
if (fs.existsSync(`${keyPath}`)) {
|
||||||
fs.unlinkSync(`${keyPath}`);
|
fs.unlinkSync(`${keyPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(`${keyPath}.pub`)) {
|
if (fs.existsSync(`${keyPath}.pub`)) {
|
||||||
fs.unlinkSync(`${keyPath}.pub`);
|
fs.unlinkSync(`${keyPath}.pub`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"-t",
|
"-t",
|
||||||
"rsa",
|
type,
|
||||||
"-b",
|
"-b",
|
||||||
"4096",
|
"4096",
|
||||||
"-C",
|
"-C",
|
||||||
"dokploy",
|
"dokploy",
|
||||||
|
"-m",
|
||||||
|
"PEM",
|
||||||
"-f",
|
"-f",
|
||||||
keyPath,
|
keyPath,
|
||||||
"-N",
|
"-N",
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await spawnAsync("ssh-keygen", args);
|
await spawnAsync("ssh-keygen", args);
|
||||||
return keyPath;
|
const data = await readSSHKey("temp");
|
||||||
} catch (error) {
|
await removeSSHKey("temp");
|
||||||
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" });
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeRSAFiles = async (appName: string) => {
|
export const removeSSHKey = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const publicKeyPath = path.join(SSH_PATH, `${appName}_rsa.pub`);
|
const publicKeyPath = path.join(SSH_PATH, `${id}_rsa.pub`);
|
||||||
const privateKeyPath = path.join(SSH_PATH, `${appName}_rsa`);
|
const privateKeyPath = path.join(SSH_PATH, `${id}_rsa`);
|
||||||
await fs.promises.unlink(publicKeyPath);
|
await fs.promises.unlink(publicKeyPath);
|
||||||
await fs.promises.unlink(privateKeyPath);
|
await fs.promises.unlink(privateKeyPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const spawnAsync = (
|
|||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve(stdout);
|
resolve(stdout);
|
||||||
} else {
|
} else {
|
||||||
const err = new Error(`child exited with code ${code}`) as Error & {
|
const err = new Error(`${stderr.toString()}`) as Error & {
|
||||||
code: number;
|
code: number;
|
||||||
stderr: BufferList;
|
stderr: BufferList;
|
||||||
stdout: BufferList;
|
stdout: BufferList;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createWriteStream } from "node:fs";
|
import { createWriteStream } from "node:fs";
|
||||||
import path, { join } from "node:path";
|
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 { APPLICATIONS_PATH, COMPOSE_PATH, SSH_PATH } from "@/server/constants";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { recreateDirectory } from "../filesystem/directory";
|
import { recreateDirectory } from "../filesystem/directory";
|
||||||
@@ -11,12 +12,12 @@ export const cloneGitRepository = async (
|
|||||||
appName: string;
|
appName: string;
|
||||||
customGitUrl?: string | null;
|
customGitUrl?: string | null;
|
||||||
customGitBranch?: string | null;
|
customGitBranch?: string | null;
|
||||||
customGitSSHKey?: string | null;
|
customGitSSHKeyId?: string | null;
|
||||||
},
|
},
|
||||||
logPath: string,
|
logPath: string,
|
||||||
isCompose = false,
|
isCompose = false,
|
||||||
) => {
|
) => {
|
||||||
const { appName, customGitUrl, customGitBranch, customGitSSHKey } = entity;
|
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
|
||||||
|
|
||||||
if (!customGitUrl || !customGitBranch) {
|
if (!customGitUrl || !customGitBranch) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -26,7 +27,7 @@ export const cloneGitRepository = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
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 basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
|
||||||
const outputPath = join(basePath, appName, "code");
|
const outputPath = join(basePath, appName, "code");
|
||||||
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
|
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
|
||||||
@@ -40,6 +41,13 @@ export const cloneGitRepository = async (
|
|||||||
`\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`,
|
`\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (customGitSSHKeyId) {
|
||||||
|
await updateSSHKeyById({
|
||||||
|
sshKeyId: customGitSSHKeyId,
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await spawnAsync(
|
await spawnAsync(
|
||||||
"git",
|
"git",
|
||||||
[
|
[
|
||||||
@@ -60,12 +68,13 @@ export const cloneGitRepository = async (
|
|||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...(customGitSSHKey && {
|
...(customGitSSHKeyId && {
|
||||||
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}`,
|
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}`,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
writeStream.write(`\nCloned Custom Git ${customGitUrl}: ✅\n`);
|
writeStream.write(`\nCloned Custom Git ${customGitUrl}: ✅\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeStream.write(`\nERROR Cloning Custom Git: ${error}: ❌\n`);
|
writeStream.write(`\nERROR Cloning Custom Git: ${error}: ❌\n`);
|
||||||
|
|||||||
@@ -13,12 +13,27 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
|||||||
const config: FileConfig = loadOrCreateConfig(appName);
|
const config: FileConfig = loadOrCreateConfig(appName);
|
||||||
const serviceName = `${appName}-service-${domain.uniqueConfigKey}`;
|
const serviceName = `${appName}-service-${domain.uniqueConfigKey}`;
|
||||||
const routerName = `${appName}-router-${domain.uniqueConfigKey}`;
|
const routerName = `${appName}-router-${domain.uniqueConfigKey}`;
|
||||||
|
const routerNameSecure = `${appName}-router-websecure-${domain.uniqueConfigKey}`;
|
||||||
|
|
||||||
config.http = config.http || { routers: {}, services: {} };
|
config.http = config.http || { routers: {}, services: {} };
|
||||||
config.http.routers = config.http.routers || {};
|
config.http.routers = config.http.routers || {};
|
||||||
config.http.services = config.http.services || {};
|
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);
|
config.http.services[serviceName] = createServiceConfig(appName, domain);
|
||||||
writeTraefikConfig(config, appName);
|
writeTraefikConfig(config, appName);
|
||||||
@@ -28,10 +43,15 @@ export const removeDomain = async (appName: string, uniqueKey: number) => {
|
|||||||
const config: FileConfig = loadOrCreateConfig(appName);
|
const config: FileConfig = loadOrCreateConfig(appName);
|
||||||
|
|
||||||
const routerKey = `${appName}-router-${uniqueKey}`;
|
const routerKey = `${appName}-router-${uniqueKey}`;
|
||||||
|
const routerSecureKey = `${appName}-router-websecure-${uniqueKey}`;
|
||||||
|
|
||||||
const serviceKey = `${appName}-service-${uniqueKey}`;
|
const serviceKey = `${appName}-service-${uniqueKey}`;
|
||||||
if (config.http?.routers?.[routerKey]) {
|
if (config.http?.routers?.[routerKey]) {
|
||||||
delete config.http.routers[routerKey];
|
delete config.http.routers[routerKey];
|
||||||
}
|
}
|
||||||
|
if (config.http?.routers?.[routerSecureKey]) {
|
||||||
|
delete config.http.routers[routerSecureKey];
|
||||||
|
}
|
||||||
if (config.http?.services?.[serviceKey]) {
|
if (config.http?.services?.[serviceKey]) {
|
||||||
delete 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 { appName, redirects, security } = app;
|
||||||
const { certificateType } = domain;
|
const { certificateType } = domain;
|
||||||
|
|
||||||
@@ -56,32 +80,33 @@ const createRouterConfig = async (app: ApplicationNested, domain: Domain) => {
|
|||||||
rule: `Host(\`${host}\`)${path ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
rule: `Host(\`${host}\`)${path ? ` && PathPrefix(\`${path}\`)` : ""}`,
|
||||||
service: `${appName}-service-${uniqueConfigKey}`,
|
service: `${appName}-service-${uniqueConfigKey}`,
|
||||||
middlewares: [],
|
middlewares: [],
|
||||||
entryPoints: https
|
entryPoints: [entryPoint],
|
||||||
? ["web", ...(process.env.NODE_ENV === "production" ? ["websecure"] : [])]
|
|
||||||
: ["web"],
|
|
||||||
tls: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (https) {
|
if (entryPoint === "web" && https) {
|
||||||
routerConfig.middlewares = ["redirect-to-https"];
|
routerConfig.middlewares = ["redirect-to-https"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirects
|
if ((entryPoint === "websecure" && https) || !https) {
|
||||||
for (const redirect of redirects) {
|
// redirects
|
||||||
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
for (const redirect of redirects) {
|
||||||
routerConfig.middlewares?.push(middlewareName);
|
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 (entryPoint === "websecure") {
|
||||||
if (security.length > 0) {
|
if (certificateType === "letsencrypt") {
|
||||||
const middlewareName = `auth-${appName}`;
|
routerConfig.tls = { certResolver: "letsencrypt" };
|
||||||
routerConfig.middlewares?.push(middlewareName);
|
} else if (certificateType === "none") {
|
||||||
}
|
routerConfig.tls = undefined;
|
||||||
|
}
|
||||||
if (certificateType === "letsencrypt") {
|
|
||||||
routerConfig.tls = { certResolver: "letsencrypt" };
|
|
||||||
} else if (certificateType === "none") {
|
|
||||||
routerConfig.tls = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return routerConfig;
|
return routerConfig;
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import {
|
|||||||
|
|
||||||
export const updateRedirectMiddleware = (appName: string, data: Redirect) => {
|
export const updateRedirectMiddleware = (appName: string, data: Redirect) => {
|
||||||
const config = loadMiddlewares<FileConfig>();
|
const config = loadMiddlewares<FileConfig>();
|
||||||
|
const middlewareName = `redirect-${appName}-${data.uniqueConfigKey}`;
|
||||||
|
|
||||||
if (config?.http?.middlewares?.[appName]) {
|
if (config?.http?.middlewares?.[middlewareName]) {
|
||||||
const middlewareName = `${appName}-${data.uniqueConfigKey}`;
|
|
||||||
|
|
||||||
config.http.middlewares[middlewareName] = {
|
config.http.middlewares[middlewareName] = {
|
||||||
redirectRegex: {
|
redirectRegex: {
|
||||||
regex: data.regex,
|
regex: data.regex,
|
||||||
|
|||||||
@@ -102,12 +102,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#terminal span {
|
|
||||||
font-family: "Inter", sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Codemirror */
|
/* Codemirror */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
@apply w-full h-full rounded-md overflow-hidden border border-solid border-border outline-none;
|
@apply w-full h-full rounded-md overflow-hidden border border-solid border-border outline-none;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
appsmith:
|
appsmith:
|
||||||
image: index.docker.io/appsmith/appsmith-ee:v1.29
|
image: index.docker.io/appsmith/appsmith-ee:v1.29
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
ports:
|
ports:
|
||||||
- ${APP_SMITH_PORT}
|
- ${APP_SMITH_PORT}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${HASH}.rule=Host(`${APP_SMITH_HOST}`)"
|
- "traefik.http.routers.${HASH}.rule=Host(`${APP_SMITH_HOST}`)"
|
||||||
- "traefik.http.services.${HASH}.loadbalancer.server.port=${APP_SMITH_PORT}"
|
- "traefik.http.services.${HASH}.loadbalancer.server.port=${APP_SMITH_PORT}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./stacks:/appsmith-stacks
|
- ../files/stacks:/appsmith-stacks
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8055
|
- 8055
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/directus/uploads
|
- ../files/uploads:/directus/uploads
|
||||||
- ./extensions:/directus/extensions
|
- ../files/extensions:/directus/extensions
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- cache
|
||||||
- database
|
- database
|
||||||
@@ -53,4 +53,4 @@ networks:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
volumes:
|
volumes:
|
||||||
directus:
|
directus:
|
||||||
|
|||||||
30
templates/jellyfin/docker-compose.yml
Normal file
30
templates/jellyfin/docker-compose.yml
Normal 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
|
||||||
22
templates/jellyfin/index.ts
Normal file
22
templates/jellyfin/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,10 +23,15 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.toml:/listmonk/config.toml
|
- ../files/config.toml:/listmonk/config.toml
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- 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:
|
app:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -41,7 +46,7 @@ services:
|
|||||||
- db
|
- db
|
||||||
- setup
|
- setup
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.toml:/listmonk/config.toml
|
- ../files/config.toml:/listmonk/config.toml
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.${HASH}.rule=Host(`${LISTMONK_HOST}`)"
|
- "traefik.http.routers.${HASH}.rule=Host(`${LISTMONK_HOST}`)"
|
||||||
@@ -50,7 +55,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
listmonk-data:
|
listmonk-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: odoo:16.0
|
image: odoo:16.0
|
||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
- "traefik.http.services.${HASH}.loadbalancer.server.port=${ODOO_PORT}"
|
- "traefik.http.services.${HASH}.loadbalancer.server.port=${ODOO_PORT}"
|
||||||
volumes:
|
volumes:
|
||||||
- odoo-web-data:/var/lib/odoo
|
- odoo-web-data:/var/lib/odoo
|
||||||
- ./config:/etc/odoo
|
- ../files/config:/etc/odoo
|
||||||
- ./addons:/mnt/extra-addons
|
- ../files/addons:/mnt/extra-addons
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
@@ -36,7 +36,6 @@ volumes:
|
|||||||
odoo-web-data:
|
odoo-web-data:
|
||||||
odoo-db-data:
|
odoo-db-data:
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- event-data:/var/lib/clickhouse
|
- event-data:/var/lib/clickhouse
|
||||||
- event-logs:/var/log/clickhouse-server
|
- event-logs:/var/log/clickhouse-server
|
||||||
- ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
|
- ../files/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-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 262144
|
soft: 262144
|
||||||
@@ -50,7 +50,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
event-logs:
|
event-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -378,4 +378,19 @@ export const templates: TemplateData[] = [
|
|||||||
tags: ["analytics"],
|
tags: ["analytics"],
|
||||||
load: () => import("./umami/index").then((m) => m.generate),
|
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),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user