mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 14:15:21 +02:00
Compare commits
154 Commits
2326-add-s
...
v0.26.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4964f70f | ||
|
|
07bf520e9b | ||
|
|
c42e859215 | ||
|
|
e666cfb374 | ||
|
|
1d9b9ff9b6 | ||
|
|
6c61919202 | ||
|
|
a9a42d2066 | ||
|
|
0f6ac310b5 | ||
|
|
c267faef08 | ||
|
|
d2be0855c1 | ||
|
|
c9aaee149a | ||
|
|
d435553839 | ||
|
|
28f40066a2 | ||
|
|
22e6a06426 | ||
|
|
94faf78f16 | ||
|
|
c4351482fa | ||
|
|
5412c5a873 | ||
|
|
212006ba9e | ||
|
|
18d12d1a6f | ||
|
|
5d5af8f57f | ||
|
|
7f8f97c48f | ||
|
|
67865c5283 | ||
|
|
817264eae4 | ||
|
|
5360df7a53 | ||
|
|
fec4daa59b | ||
|
|
aae7906e77 | ||
|
|
86d14465cb | ||
|
|
f84c659121 | ||
|
|
89cb9c24c9 | ||
|
|
c7fcea7d6a | ||
|
|
d4555e6985 | ||
|
|
daa54cea8d | ||
|
|
77aff700fd | ||
|
|
cdb0de9a72 | ||
|
|
a5353e5457 | ||
|
|
a9b8beb50b | ||
|
|
6022f2f6a3 | ||
|
|
075e387bb6 | ||
|
|
568293ef3c | ||
|
|
a9ae39dc94 | ||
|
|
55a9640e31 | ||
|
|
32d5959733 | ||
|
|
bccd531457 | ||
|
|
f5de5130f3 | ||
|
|
bd751658be | ||
|
|
9b23aa9c8c | ||
|
|
dbc1396fa6 | ||
|
|
4210eefd37 | ||
|
|
91050ce3a5 | ||
|
|
9394d97163 | ||
|
|
f91a3aab25 | ||
|
|
9fbd0dce9a | ||
|
|
9e405c0728 | ||
|
|
44892404c1 | ||
|
|
1362fdd4b4 | ||
|
|
c7c3b1018b | ||
|
|
0d9b72e00a | ||
|
|
80ed041420 | ||
|
|
ba9c2ef369 | ||
|
|
8bd4f403c4 | ||
|
|
7ea7ee739f | ||
|
|
4873baa975 | ||
|
|
287dfb5402 | ||
|
|
439fba1f4b | ||
|
|
1ba24630a8 | ||
|
|
de6c1a7981 | ||
|
|
7948721f5a | ||
|
|
99cb80757c | ||
|
|
7467ada3a9 | ||
|
|
f0f2188652 | ||
|
|
2c1dfe9377 | ||
|
|
9e8efab909 | ||
|
|
35612b21a0 | ||
|
|
7873af1c39 | ||
|
|
ade727e2ed | ||
|
|
ac1fb6fb86 | ||
|
|
b3168f75d0 | ||
|
|
0e7b550642 | ||
|
|
3e1030edda | ||
|
|
3ea90de4e1 | ||
|
|
bccef0da4c | ||
|
|
dc28ddba2a | ||
|
|
ed312dc1c0 | ||
|
|
6cafb15dbb | ||
|
|
c34fdf7a46 | ||
|
|
e627c9af99 | ||
|
|
18e609313b | ||
|
|
fbf840bf6e | ||
|
|
76613de095 | ||
|
|
5a7f55ea63 | ||
|
|
be3403af0c | ||
|
|
f190cc548c | ||
|
|
4df9b935a8 | ||
|
|
b9becbafd8 | ||
|
|
60be376a4f | ||
|
|
ef9732d5d9 | ||
|
|
d35307ead6 | ||
|
|
c98db390dc | ||
|
|
0c7265c9c9 | ||
|
|
f1ef1d8489 | ||
|
|
fbd095334c | ||
|
|
e3832eff07 | ||
|
|
25b7069e31 | ||
|
|
0fbb063d06 | ||
|
|
56d4e61c1f | ||
|
|
7ce36a50e8 | ||
|
|
4f5b557a60 | ||
|
|
d09163a24e | ||
|
|
e1d8505757 | ||
|
|
b6de55c4d9 | ||
|
|
e22d503182 | ||
|
|
32631e957a | ||
|
|
79d3c1d7f3 | ||
|
|
9213061c26 | ||
|
|
085ef35b46 | ||
|
|
c9f356e314 | ||
|
|
4f691d27b2 | ||
|
|
3c70db9fc8 | ||
|
|
5890b321b2 | ||
|
|
d02913d69e | ||
|
|
c459997453 | ||
|
|
1c0673b327 | ||
|
|
334d9c91ef | ||
|
|
615d89ee0c | ||
|
|
0c24507872 | ||
|
|
d2cd01aff7 | ||
|
|
6349cabf27 | ||
|
|
94536ab05a | ||
|
|
8e5be8dbcb | ||
|
|
046606e496 | ||
|
|
e9cf1f4caa | ||
|
|
ee0a299343 | ||
|
|
1b77c8029b | ||
|
|
48e4fd3ddf | ||
|
|
276f870e74 | ||
|
|
6e86fafa5e | ||
|
|
008788a38a | ||
|
|
b4a14e6e76 | ||
|
|
e64ee98d99 | ||
|
|
95d0da25a0 | ||
|
|
0cc8c02359 | ||
|
|
7f3fe52b53 | ||
|
|
b5bc384664 | ||
|
|
39d0b9649f | ||
|
|
1ce880bd6d | ||
|
|
8a8ed58fef | ||
|
|
95714c1749 | ||
|
|
a181b7b8b8 | ||
|
|
0e2f1e2832 | ||
|
|
2ec495b2f2 | ||
|
|
178ccb3f45 | ||
|
|
a47a5f3b9e | ||
|
|
95bf60ac75 | ||
|
|
544408886e |
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
||||
|
||||
|
||||
# Deploy only the dokploy app
|
||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
*
|
||||
* These tests verify that the Host rule is generated with the correct format:
|
||||
* - Host(`domain.com`) - with opening and closing parentheses
|
||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||
*
|
||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||
* (missing opening parenthesis) which broke all domain routing.
|
||||
*/
|
||||
describe("Host rule format regression tests", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
domainType: "compose",
|
||||
serviceName: "test-app",
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
it("should generate Host rule with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify exact format: Host(`domain`)
|
||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||
// Ensure opening parenthesis is present after Host
|
||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||
// Ensure it does NOT have the malformed format
|
||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api" },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify PathPrefix format
|
||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||
// Ensure opening parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api/v1" },
|
||||
"websecure",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toBe(
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML serialization preserves Host rule format", () => {
|
||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Simulate compose file structure
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
image: "nginx",
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stringify to YAML
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
|
||||
// Parse back
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
// Verify format is preserved
|
||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should preserve complex rule format through YAML serialization", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api", https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
expect(parsedRuleLabel).toContain(
|
||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for domain names", () => {
|
||||
const domainCases = [
|
||||
{ name: "simple domain", host: "example.com" },
|
||||
{ name: "subdomain", host: "app.example.com" },
|
||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||
{ name: "numeric domain", host: "123.example.com" },
|
||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||
{ name: "localhost", host: "localhost" },
|
||||
{ name: "IP address style", host: "192.168.1.100" },
|
||||
];
|
||||
|
||||
for (const { name, host } of domainCases) {
|
||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, host },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).toMatch(
|
||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Multiple domains scenario", () => {
|
||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const websecureLabels = await createDomainLabels(
|
||||
"test-app",
|
||||
baseDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Both should have correct format
|
||||
expect(webRule).toContain("Host(`example.com`)");
|
||||
expect(websecureRule).toContain("Host(`example.com`)");
|
||||
|
||||
// Neither should have malformed format
|
||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special characters in paths", () => {
|
||||
const pathCases = [
|
||||
{ name: "simple path", path: "/api" },
|
||||
{ name: "nested path", path: "/api/v1/users" },
|
||||
{ name: "path with hyphen", path: "/api-v1" },
|
||||
{ name: "path with underscore", path: "/api_v1" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pathCases) {
|
||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
||||
|
||||
it("should verify nixpacks command is called with correct app", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
@@ -220,7 +220,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
||||
);
|
||||
|
||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
@@ -241,7 +241,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
||||
|
||||
it("should execute commands in correct order", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
@@ -260,7 +260,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
||||
|
||||
it("should include log redirection in command", async () => {
|
||||
const mockCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
|
||||
@@ -41,6 +41,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
|
||||
@@ -18,6 +18,8 @@ const baseAdmin: User = {
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -61,7 +63,6 @@ const baseAdmin: User = {
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
name: "",
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
|
||||
@@ -17,6 +17,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
|
||||
@@ -143,7 +143,7 @@ export const ShowDeployments = ({
|
||||
See the last 10 deployments for this {type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
@@ -373,7 +373,19 @@ export const ShowDeployments = ({
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
description={
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Are you sure you want to rollback to this
|
||||
deployment?
|
||||
</p>
|
||||
<AlertBlock type="info" className="text-sm">
|
||||
Please wait a few seconds while the image is
|
||||
pulled from the registry. Your application
|
||||
should be running shortly.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
}
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,13 +21,37 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!registries || registries.length === 0 ? (
|
||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||
No registries available. Please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||
>
|
||||
configure a registry
|
||||
</Link>{" "}
|
||||
first to enable rollbacks.
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
@@ -60,6 +60,30 @@ export const commonCronExpressions = [
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
export const commonTimezones = [
|
||||
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
|
||||
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
|
||||
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
|
||||
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
|
||||
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||
{
|
||||
label: "America/Mexico_City (Central Mexico)",
|
||||
value: "America/Mexico_City",
|
||||
},
|
||||
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
|
||||
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
|
||||
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||
{
|
||||
label: "Australia/Sydney (Australian Eastern Time)",
|
||||
value: "Australia/Sydney",
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -75,6 +99,7 @@ const formSchema = z
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
@@ -213,6 +238,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
timezone: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -251,6 +277,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
timezone: schedule.timezone || undefined,
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
@@ -464,6 +491,54 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Timezone
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Select a timezone for the schedule. If not
|
||||
specified, UTC will be used.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="UTC (default)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonTimezones.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Optional: Choose a timezone for the schedule execution time
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(scheduleTypeForm === "application" ||
|
||||
scheduleTypeForm === "compose") && (
|
||||
<>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||
// message: "The server is running on port 8080" }
|
||||
const logRegex =
|
||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
||||
|
||||
return logString
|
||||
.split("\n")
|
||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
const match = line.match(logRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const [, , timestamp, message] = match;
|
||||
const { timestamp, message } = match.groups ?? {};
|
||||
|
||||
if (!message?.trim()) return null;
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Successfully impersonating user", {
|
||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedUser.name || ""}
|
||||
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name || ""}
|
||||
{`${user.name} ${user.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email} • {user.role}
|
||||
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
|
||||
<AvatarImage
|
||||
className="object-cover"
|
||||
src={data?.user?.image || ""}
|
||||
alt={data?.user?.name || ""}
|
||||
alt={
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""
|
||||
}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||
"U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => {
|
||||
Impersonating
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{data?.user?.name || ""}
|
||||
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: 0,
|
||||
value: "0%",
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: number;
|
||||
value: string;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Terminal,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -246,20 +239,6 @@ export const AdvancedEnvironmentSelector = ({
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Action buttons for non-production environments */}
|
||||
{/* <EnvironmentVariables environmentId={environment.environmentId}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
</Button>
|
||||
</EnvironmentVariables> */}
|
||||
{environment.name !== "production" && (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<Button
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
@@ -54,16 +55,23 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading } = api.project.all.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||
);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const [sortBy, setSortBy] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||
@@ -75,14 +83,41 @@ export const ShowProjects = () => {
|
||||
localStorage.setItem("projectsSort", sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (urlQuery !== searchQuery) {
|
||||
setSearchQuery(urlQuery);
|
||||
}
|
||||
}, [router.isReady, router.query.q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (debouncedSearchQuery === urlQuery) return;
|
||||
|
||||
const newQuery = { ...router.query };
|
||||
if (debouncedSearchQuery) {
|
||||
newQuery.q = debouncedSearchQuery;
|
||||
} else {
|
||||
delete newQuery.q;
|
||||
}
|
||||
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
// First filter by search query
|
||||
const filtered = data.filter(
|
||||
(project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
project.name
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
project.description
|
||||
?.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Then sort the filtered results
|
||||
@@ -130,7 +165,7 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, searchQuery, sortBy]);
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -65,6 +65,25 @@ export const ShowRequests = () => {
|
||||
to: Date | undefined;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Check if logs exist to determine if traefik has been reloaded
|
||||
// Only fetch when active to minimize network calls
|
||||
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
page: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!isActive,
|
||||
refetchInterval: 5000, // Check every 5 seconds when active
|
||||
},
|
||||
);
|
||||
|
||||
// Determine if warning should be shown
|
||||
// Show warning only if active but no logs exist yet
|
||||
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (logCleanupStatus) {
|
||||
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
||||
@@ -85,16 +104,18 @@ export const ShowRequests = () => {
|
||||
See all the incoming requests that pass trough Traefik
|
||||
</CardDescription>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
{shouldShowWarning && (
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex w-full gap-4 justify-end items-center">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -44,6 +50,7 @@ const notificationBaseSchema = z.object({
|
||||
appDeploy: z.boolean().default(false),
|
||||
appBuildError: z.boolean().default(false),
|
||||
databaseBackup: z.boolean().default(false),
|
||||
volumeBackup: z.boolean().default(false),
|
||||
dokployRestart: z.boolean().default(false),
|
||||
dockerCleanup: z.boolean().default(false),
|
||||
serverThreshold: z.boolean().default(false),
|
||||
@@ -107,6 +114,21 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("custom"),
|
||||
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("lark"),
|
||||
@@ -144,6 +166,10 @@ export const notificationsMap = {
|
||||
icon: <NtfyIcon />,
|
||||
label: "ntfy",
|
||||
},
|
||||
custom: {
|
||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||
label: "Custom",
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||
@@ -179,6 +205,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testNtfyConnection.useMutation();
|
||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||
api.notification.testLarkConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||
api.notification.testCustomConnection.useMutation();
|
||||
|
||||
const customMutation = notificationId
|
||||
? api.notification.updateCustom.useMutation()
|
||||
: api.notification.createCustom.useMutation();
|
||||
const slackMutation = notificationId
|
||||
? api.notification.updateSlack.useMutation()
|
||||
: api.notification.createSlack.useMutation();
|
||||
@@ -217,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: headerFields,
|
||||
append: appendHeader,
|
||||
remove: removeHeader,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "headers" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
append("");
|
||||
@@ -231,6 +273,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
webhookUrl: notification.slack?.webhookUrl,
|
||||
channel: notification.slack?.channel || "",
|
||||
@@ -244,6 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
botToken: notification.telegram?.botToken,
|
||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||
chatId: notification.telegram?.chatId,
|
||||
@@ -258,6 +302,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.discord?.webhookUrl,
|
||||
decoration: notification.discord?.decoration || undefined,
|
||||
@@ -271,6 +316,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
smtpServer: notification.email?.smtpServer,
|
||||
smtpPort: notification.email?.smtpPort,
|
||||
@@ -288,6 +334,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
appToken: notification.gotify?.appToken,
|
||||
decoration: notification.gotify?.decoration || undefined,
|
||||
@@ -302,6 +349,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
accessToken: notification.ntfy?.accessToken || "",
|
||||
topic: notification.ntfy?.topic,
|
||||
@@ -323,6 +371,26 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "custom") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
endpoint: notification.custom?.endpoint || "",
|
||||
headers: notification.custom?.headers
|
||||
? Object.entries(notification.custom.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.reset();
|
||||
@@ -337,6 +405,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
custom: customMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
@@ -345,6 +414,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
volumeBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
} = data;
|
||||
@@ -355,6 +425,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
name: data.name,
|
||||
@@ -369,6 +440,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
botToken: data.botToken,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
chatId: data.chatId,
|
||||
@@ -384,6 +456,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
name: data.name,
|
||||
@@ -398,6 +471,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
@@ -416,6 +490,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
@@ -431,6 +506,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
accessToken: data.accessToken || "",
|
||||
topic: data.topic,
|
||||
@@ -453,6 +529,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
larkId: notification?.larkId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
// Convert headers array to object
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
promise = customMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
customId: notification?.customId || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -1043,7 +1145,92 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type === "custom" && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.example.com/webhook"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL where POST requests will be sent with
|
||||
notification data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional. Custom headers for your POST request (e.g.,
|
||||
Authorization, Content-Type).
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{headerFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.key` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.value` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendHeader({ key: "", value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === "lark" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1134,6 +1321,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volumeBackup"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Volume Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a volume backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerCleanup"
|
||||
@@ -1215,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingEmail ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark
|
||||
isLoadingLark ||
|
||||
isLoadingCustom
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
@@ -1269,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
await testLarkConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
await testCustomConnection({
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
|
||||
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DiscordIcon,
|
||||
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
|
||||
<NtfyIcon className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "custom" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<PenBoxIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "lark" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
<LarkIcon className="size-7 text-muted-foreground" />
|
||||
|
||||
@@ -41,6 +41,7 @@ const profileSchema = z.object({
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
allowImpersonation: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
@@ -88,7 +89,8 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||
name: data?.user?.name || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
@@ -102,7 +104,8 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
allowImpersonation: data?.user?.allowImpersonation,
|
||||
name: data?.user?.name || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
@@ -127,6 +130,7 @@ export const ProfileForm = () => {
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
name: values.name || undefined,
|
||||
lastName: values.lastName || undefined,
|
||||
});
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
@@ -136,6 +140,7 @@ export const ProfileForm = () => {
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
name: values.name || "",
|
||||
lastName: values.lastName || "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Error updating the profile");
|
||||
@@ -180,9 +185,22 @@ export const ProfileForm = () => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} />
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -280,7 +298,7 @@ export const ProfileForm = () => {
|
||||
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getFallbackAvatarInitials(
|
||||
data?.user?.name,
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
<DropdownMenuItem
|
||||
<DialogAction
|
||||
title={
|
||||
haveTraefikDashboardPortEnabled
|
||||
? "Disable Traefik Dashboard"
|
||||
: "Enable Traefik Dashboard"
|
||||
}
|
||||
description={
|
||||
<div className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
<p>
|
||||
Are you sure you want to{" "}
|
||||
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
||||
Traefik dashboard?
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||
Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -158,6 +158,7 @@ export const AddInvitation = () => {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
||||
159
apps/dokploy/components/dashboard/settings/users/change-role.tsx
Normal file
159
apps/dokploy/components/dashboard/settings/users/change-role.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
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 { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const changeRoleSchema = z.object({
|
||||
role: z.enum(["admin", "member"]),
|
||||
});
|
||||
|
||||
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
||||
|
||||
interface Props {
|
||||
memberId: string;
|
||||
currentRole: "admin" | "member";
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.organization.updateMemberRole.useMutation();
|
||||
|
||||
const form = useForm<ChangeRoleSchema>({
|
||||
defaultValues: {
|
||||
role: currentRole,
|
||||
},
|
||||
resolver: zodResolver(changeRoleSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
form.reset({
|
||||
role: currentRole,
|
||||
});
|
||||
}
|
||||
}, [form, currentRole, isOpen]);
|
||||
|
||||
const onSubmit = async (data: ChangeRoleSchema) => {
|
||||
await mutateAsync({
|
||||
memberId,
|
||||
role: data.role,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Role updated successfully");
|
||||
await utils.user.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message || "Error updating role");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Change Role
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change User Role</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the role for <strong>{userEmail}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-change-role"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
<strong>Admin:</strong> Can manage users and settings.
|
||||
<br />
|
||||
<strong>Member:</strong> Limited permissions, can be
|
||||
customized.
|
||||
<br />
|
||||
<em className="text-muted-foreground text-xs">
|
||||
Note: Owner role is intransferible.
|
||||
</em>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-change-role"
|
||||
type="submit"
|
||||
>
|
||||
Update Role
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -29,12 +29,15 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { AddUserPermissions } from "./add-permissions";
|
||||
import { ChangeRole } from "./change-role";
|
||||
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -81,6 +84,52 @@ export const ShowUsers = () => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((member) => {
|
||||
const currentUserRole = data?.find(
|
||||
(m) => m.user.id === session?.user?.id,
|
||||
)?.role;
|
||||
|
||||
// Owner never has "Edit Permissions" (they're absolute owner)
|
||||
// Other users can edit permissions if target is not themselves and target is a member
|
||||
const canEditPermissions =
|
||||
member.role !== "owner" &&
|
||||
member.role === "member" &&
|
||||
member.user.id !== session?.user?.id;
|
||||
|
||||
// Can change role based on hierarchy:
|
||||
// - Owner: Can change anyone's role (except themselves and other owners)
|
||||
// - Admin: Can only change member roles (not other admins or owners)
|
||||
// - Owner role is intransferible
|
||||
const canChangeRole =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
// Delete/Unlink follow same hierarchy as role changes
|
||||
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
|
||||
// - Admin: Can only delete/unlink members (not other admins or owner)
|
||||
const canDelete =
|
||||
member.role !== "owner" &&
|
||||
!isCloud &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
const canUnlink =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
|
||||
const hasAnyAction =
|
||||
canEditPermissions ||
|
||||
canChangeRole ||
|
||||
canDelete ||
|
||||
canUnlink;
|
||||
|
||||
return (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="w-[100px]">
|
||||
@@ -109,7 +158,7 @@ export const ShowUsers = () => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right flex justify-end">
|
||||
{member.role !== "owner" && (
|
||||
{hasAnyAction ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -127,11 +176,23 @@ export const ShowUsers = () => {
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
{canChangeRole && (
|
||||
<ChangeRole
|
||||
memberId={member.id}
|
||||
currentRole={
|
||||
member.role as "admin" | "member"
|
||||
}
|
||||
userEmail={member.user.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCloud && (
|
||||
{canEditPermissions && (
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
@@ -146,9 +207,10 @@ export const ShowUsers = () => {
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
err?.message ||
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
}}
|
||||
@@ -162,66 +224,79 @@ export const ShowUsers = () => {
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
await utils.user.checkUserOrganizations.fetch(
|
||||
{
|
||||
{canUnlink && (
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
await utils.user.checkUserOrganizations.fetch(
|
||||
{
|
||||
userId: member.user.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (orgCount === 1) {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } =
|
||||
await authClient.organization.removeMember(
|
||||
{
|
||||
memberIdOrEmail: member.id,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(orgCount);
|
||||
|
||||
if (orgCount === 1) {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
return;
|
||||
if (!error) {
|
||||
toast.success(
|
||||
"User unlinked successfully",
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
toast.error(
|
||||
"Error unlinking user",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } =
|
||||
await authClient.organization.removeMember(
|
||||
{
|
||||
memberIdOrEmail: member.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (!error) {
|
||||
toast.success(
|
||||
"User unlinked successfully",
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
toast.error("Error unlinking user");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
}}
|
||||
>
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled
|
||||
>
|
||||
<span className="sr-only">
|
||||
No actions available
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string(),
|
||||
domain: z.string().trim().toLowerCase(),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
@@ -49,7 +49,11 @@ const addServerDomain = z
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
if (
|
||||
data.https &&
|
||||
data.certificateType === "letsencrypt" &&
|
||||
!data.letsEncryptEmail
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
|
||||
@@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<ScrollArea className="pr-4">
|
||||
<div className="grid gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={field.id} className="bg-transparent">
|
||||
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
|
||||
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ports.${index}.targetPort`}
|
||||
@@ -303,6 +305,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -158,7 +158,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/schedules",
|
||||
icon: Clock,
|
||||
// Only enabled in non-cloud environments
|
||||
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
|
||||
isEnabled: ({ isCloud, auth }) =>
|
||||
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -168,7 +169,9 @@ const MENU: Menu = {
|
||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToTraefikFiles) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
@@ -179,7 +182,12 @@ const MENU: Menu = {
|
||||
icon: BlocksIcon,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -188,7 +196,12 @@ const MENU: Menu = {
|
||||
icon: PieChart,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -197,7 +210,12 @@ const MENU: Menu = {
|
||||
icon: Forward,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
|
||||
// Legacy unused menu, adjusted to the new structure
|
||||
@@ -264,7 +282,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/server",
|
||||
icon: Activity,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -278,7 +297,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/servers",
|
||||
icon: Server,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -286,7 +306,8 @@ const MENU: Menu = {
|
||||
icon: Users,
|
||||
url: "/dashboard/settings/users",
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -295,14 +316,19 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/ssh-keys",
|
||||
// Only enabled for admins and users with access to SSH keys
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
||||
!!(
|
||||
auth?.role === "owner" ||
|
||||
auth?.canAccessToSSHKeys ||
|
||||
auth?.role === "admin"
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "AI",
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -311,7 +337,11 @@ const MENU: Menu = {
|
||||
icon: GitBranch,
|
||||
// Only enabled for admins and users with access to Git providers
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
|
||||
!!(
|
||||
auth?.role === "owner" ||
|
||||
auth?.canAccessToGitProviders ||
|
||||
auth?.role === "admin"
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -319,7 +349,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/registry",
|
||||
icon: Package,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -327,7 +358,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/destinations",
|
||||
icon: Database,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
|
||||
{
|
||||
@@ -336,7 +368,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/certificates",
|
||||
icon: ShieldCheck,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -344,7 +377,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/cluster",
|
||||
icon: Boxes,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -352,7 +386,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/notifications",
|
||||
icon: Bell,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -718,7 +753,9 @@ function SidebarLogo() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(user?.role === "owner" || isCloud) && (
|
||||
{(user?.role === "owner" ||
|
||||
user?.role === "admin" ||
|
||||
isCloud) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AddOrganization />
|
||||
@@ -1082,7 +1119,7 @@ export default function Page({ children }: Props) {
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu className="flex flex-col gap-2">
|
||||
{!isCloud && auth?.role === "owner" && (
|
||||
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
|
||||
<SidebarMenuItem>
|
||||
<UpdateServerButton />
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -49,7 +49,9 @@ export const UserNav = () => {
|
||||
alt={data?.user?.image || ""}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getFallbackAvatarInitials(data?.user?.name)}
|
||||
{getFallbackAvatarInitials(
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
@@ -102,7 +104,9 @@ export const UserNav = () => {
|
||||
>
|
||||
Monitoring
|
||||
</DropdownMenuItem>
|
||||
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
|
||||
{(data?.role === "owner" ||
|
||||
data?.role === "admin" ||
|
||||
data?.canAccessToTraefikFiles) && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -112,7 +116,9 @@ export const UserNav = () => {
|
||||
Traefik
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(data?.role === "owner" || data?.canAccessToDocker) && (
|
||||
{(data?.role === "owner" ||
|
||||
data?.role === "admin" ||
|
||||
data?.canAccessToDocker) && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -126,7 +132,7 @@ export const UserNav = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
data?.role === "owner" && (
|
||||
(data?.role === "owner" || data?.role === "admin") && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
|
||||
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0126_nifty_monster_badoon.sql
Normal file
1
apps/dokploy/drizzle/0126_nifty_monster_badoon.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ALTER COLUMN "enableDockerCleanup" SET DEFAULT true;
|
||||
1
apps/dokploy/drizzle/0127_superb_alice.sql
Normal file
1
apps/dokploy/drizzle/0127_superb_alice.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "notification" ADD COLUMN "volumeBackup" boolean DEFAULT false NOT NULL;
|
||||
2
apps/dokploy/drizzle/0128_hard_falcon.sql
Normal file
2
apps/dokploy/drizzle/0128_hard_falcon.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" RENAME COLUMN "name" TO "firstName";--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "lastName" text DEFAULT '' NOT NULL;
|
||||
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
|
||||
CREATE TABLE "custom" (
|
||||
"customId" text PRIMARY KEY NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"headers" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0130_perpetual_screwball.sql
Normal file
1
apps/dokploy/drizzle/0130_perpetual_screwball.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "schedule" ADD COLUMN "timezone" text;
|
||||
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6850
apps/dokploy/drizzle/meta/0126_snapshot.json
Normal file
6850
apps/dokploy/drizzle/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6857
apps/dokploy/drizzle/meta/0127_snapshot.json
Normal file
6857
apps/dokploy/drizzle/meta/0127_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6864
apps/dokploy/drizzle/meta/0128_snapshot.json
Normal file
6864
apps/dokploy/drizzle/meta/0128_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6921
apps/dokploy/drizzle/meta/0130_snapshot.json
Normal file
6921
apps/dokploy/drizzle/meta/0130_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -876,6 +876,48 @@
|
||||
"when": 1764571454170,
|
||||
"tag": "0124_certain_cloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 125,
|
||||
"version": "7",
|
||||
"when": 1764573207555,
|
||||
"tag": "0125_neat_the_phantom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 126,
|
||||
"version": "7",
|
||||
"when": 1765065295708,
|
||||
"tag": "0126_nifty_monster_badoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 127,
|
||||
"version": "7",
|
||||
"when": 1765095189368,
|
||||
"tag": "0127_superb_alice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 128,
|
||||
"version": "7",
|
||||
"when": 1765101709413,
|
||||
"tag": "0128_hard_falcon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 129,
|
||||
"version": "7",
|
||||
"when": 1765136384035,
|
||||
"tag": "0129_pale_roughhouse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 130,
|
||||
"version": "7",
|
||||
"when": 1765167657813,
|
||||
"tag": "0130_perpetual_screwball",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
adminClient,
|
||||
apiKeyClient,
|
||||
inferAdditionalFields,
|
||||
organizationClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
@@ -13,5 +14,12 @@ export const authClient = createAuthClient({
|
||||
twoFactorClient(),
|
||||
apiKeyClient(),
|
||||
adminClient(),
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
lastName: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.25.11",
|
||||
"version": "v0.26.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -118,7 +118,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^15.3.2",
|
||||
"next": "^16.0.7",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
@@ -43,14 +42,6 @@ const MyApp = ({
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
{process.env.NEXT_PUBLIC_UMAMI_HOST &&
|
||||
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
||||
<Script
|
||||
src={process.env.NEXT_PUBLIC_UMAMI_HOST}
|
||||
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getServerSideProps(
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
if (!user || (user.role !== "owner" && user.role !== "admin")) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function getServerSideProps(
|
||||
}
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member") {
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
@@ -18,7 +18,9 @@ const Page = () => {
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<ProfileForm />
|
||||
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
|
||||
{(data?.canAccessToAPI ||
|
||||
data?.role === "owner" ||
|
||||
data?.role === "admin") && <ShowApiKeys />}
|
||||
|
||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,10 @@ import { api } from "@/utils/api";
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().min(1, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
@@ -92,6 +95,7 @@ const Invitation = ({
|
||||
const form = useForm<Register>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -115,6 +119,7 @@ const Invitation = ({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
name: values.name,
|
||||
lastName: values.lastName,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-dokploy-token": token,
|
||||
@@ -197,12 +202,22 @@ const Invitation = ({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your name"
|
||||
{...field}
|
||||
/>
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -27,7 +27,10 @@ import { authClient } from "@/lib/auth-client";
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().min(1, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
email: z
|
||||
.string()
|
||||
@@ -79,6 +82,7 @@ const Register = ({ isCloud }: Props) => {
|
||||
const form = useForm<Register>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -95,6 +99,7 @@ const Register = ({ isCloud }: Props) => {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
name: values.name,
|
||||
lastName: values.lastName,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -158,9 +163,22 @@ const Register = ({ isCloud }: Props) => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="name" {...field} />
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { findAdmin } from "@dokploy/server";
|
||||
import { findOwner } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { user } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await findAdmin();
|
||||
const result = await findOwner();
|
||||
|
||||
const update = await db
|
||||
.update(user)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { findAdmin, generateRandomPassword } from "@dokploy/server";
|
||||
import { findOwner, generateRandomPassword } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { account } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
||||
try {
|
||||
const randomPassword = await generateRandomPassword();
|
||||
|
||||
const result = await findAdmin();
|
||||
const result = await findOwner();
|
||||
|
||||
const update = await db
|
||||
.update(account)
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
createMount,
|
||||
deployMariadb,
|
||||
findBackupsByDbId,
|
||||
findMariadbById,
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
createMount,
|
||||
deployMongo,
|
||||
findBackupsByDbId,
|
||||
findMongoById,
|
||||
findEnvironmentById,
|
||||
findMongoById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
createCustomNotification,
|
||||
createDiscordNotification,
|
||||
createEmailNotification,
|
||||
createGotifyNotification,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
findNotificationById,
|
||||
IS_CLOUD,
|
||||
removeNotificationById,
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendGotifyNotification,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
sendServerThresholdNotifications,
|
||||
sendSlackNotification,
|
||||
sendTelegramNotification,
|
||||
updateCustomNotification,
|
||||
updateDiscordNotification,
|
||||
updateEmailNotification,
|
||||
updateGotifyNotification,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
} from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateCustom,
|
||||
apiCreateDiscord,
|
||||
apiCreateEmail,
|
||||
apiCreateGotify,
|
||||
@@ -44,6 +48,7 @@ import {
|
||||
apiCreateSlack,
|
||||
apiCreateTelegram,
|
||||
apiFindOneNotification,
|
||||
apiTestCustomConnection,
|
||||
apiTestDiscordConnection,
|
||||
apiTestEmailConnection,
|
||||
apiTestGotifyConnection,
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
apiTestNtfyConnection,
|
||||
apiTestSlackConnection,
|
||||
apiTestTelegramConnection,
|
||||
apiUpdateCustom,
|
||||
apiUpdateDiscord,
|
||||
apiUpdateEmail,
|
||||
apiUpdateGotify,
|
||||
@@ -334,6 +340,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
email: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
},
|
||||
orderBy: desc(notifications.createdAt),
|
||||
@@ -518,6 +525,59 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
createCustom: adminProcedure
|
||||
.input(apiCreateCustom)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createCustomNotification(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateCustom: adminProcedure
|
||||
.input(apiUpdateCustom)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
if (notification.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this notification",
|
||||
});
|
||||
}
|
||||
return await updateCustomNotification({
|
||||
...input,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
testCustomConnection: adminProcedure
|
||||
.input(apiTestCustomConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendCustomNotification(input, {
|
||||
title: "Test Notification",
|
||||
message: "Hi, From Dokploy 👋",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createLark: adminProcedure
|
||||
.input(apiCreateLark)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can create an organization",
|
||||
@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
console.log("result", result);
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
@@ -82,7 +80,22 @@ export const organizationRouter = createTRPCRouter({
|
||||
organizationId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
return await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
@@ -96,12 +109,45 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
// First, verify the organization exists
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can update the organization
|
||||
// Verify the user is either the organization owner or has the owner role
|
||||
const isOwner =
|
||||
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||
|
||||
if (!isOwner) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can update it",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(organization)
|
||||
.set({
|
||||
@@ -119,12 +165,7 @@ export const organizationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can delete it",
|
||||
});
|
||||
}
|
||||
// First, verify the organization exists
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, input.organizationId),
|
||||
});
|
||||
@@ -136,7 +177,27 @@ export const organizationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (org.ownerId !== ctx.user.id) {
|
||||
// Verify user is a member of this organization
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can delete the organization
|
||||
// Verify the user is either the organization owner or has the owner role
|
||||
const isOwner =
|
||||
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||
|
||||
if (!isOwner) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the organization owner can delete it",
|
||||
@@ -194,6 +255,65 @@ export const organizationRouter = createTRPCRouter({
|
||||
.delete(invitation)
|
||||
.where(eq(invitation.id, input.invitationId));
|
||||
}),
|
||||
updateMemberRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
role: z.enum(["admin", "member"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Fetch the target member
|
||||
const target = await db.query.member.findFirst({
|
||||
where: eq(member.id, input.memberId),
|
||||
with: { user: true },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
||||
}
|
||||
|
||||
if (target.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to update this member's role",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent users from changing their own role
|
||||
if (target.userId === ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot change your own role",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner role is intransferible - cannot change to or from owner
|
||||
if (target.role === "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "The owner role is intransferible",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can change admin roles
|
||||
// Admins can only change member roles
|
||||
if (ctx.user.role === "admin" && target.role === "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Only the organization owner can change admin roles. Admins can only modify member roles.",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the target member's role
|
||||
await db
|
||||
.update(member)
|
||||
.set({ role: input.role })
|
||||
.where(eq(member.id, input.memberId));
|
||||
|
||||
return true;
|
||||
}),
|
||||
setDefault: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
scheduleId: newSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: newSchedule.cronExpression,
|
||||
timezone: newSchedule.timezone,
|
||||
});
|
||||
} else {
|
||||
scheduleJob(newSchedule);
|
||||
@@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
timezone: updatedSchedule.timezone,
|
||||
});
|
||||
} else {
|
||||
await removeJob({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
canAccessToTraefikFiles,
|
||||
checkGPUStatus,
|
||||
cleanStoppedContainers,
|
||||
cleanUpDockerBuilder,
|
||||
cleanUpSystemPrune,
|
||||
cleanUpUnusedImages,
|
||||
cleanUpUnusedVolumes,
|
||||
checkPortInUse,
|
||||
cleanupAll,
|
||||
cleanupBuilders,
|
||||
cleanupContainers,
|
||||
cleanupImages,
|
||||
cleanupSystem,
|
||||
cleanupVolumes,
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
|
||||
let newPorts = ports;
|
||||
// If receive true, add 8080 to ports
|
||||
if (input.enableDashboard) {
|
||||
// Check if port 8080 is already in use before enabling dashboard
|
||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||
if (portCheck.isInUse) {
|
||||
const conflictingContainer = portCheck.conflictingContainer
|
||||
? ` by container "${portCheck.conflictingContainer}"`
|
||||
: "";
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
});
|
||||
}
|
||||
newPorts.push({
|
||||
targetPort: 8080,
|
||||
publishedPort: 8080,
|
||||
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
|
||||
cleanUnusedImages: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedImages(input?.serverId);
|
||||
await cleanupImages(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanUnusedVolumes: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedVolumes(input?.serverId);
|
||||
await cleanupVolumes(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanStoppedContainers: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanStoppedContainers(input?.serverId);
|
||||
await cleanupContainers(input?.serverId);
|
||||
return true;
|
||||
}),
|
||||
cleanDockerBuilder: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanupBuilders(input?.serverId);
|
||||
}),
|
||||
cleanDockerPrune: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpSystemPrune(input?.serverId);
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanupSystem(input?.serverId);
|
||||
await cleanupBuilders(input?.serverId);
|
||||
|
||||
return true;
|
||||
}),
|
||||
cleanAll: adminProcedure
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await cleanUpUnusedImages(input?.serverId);
|
||||
await cleanStoppedContainers(input?.serverId);
|
||||
await cleanUpDockerBuilder(input?.serverId);
|
||||
await cleanUpSystemPrune(input?.serverId);
|
||||
await cleanupAll(input?.serverId);
|
||||
|
||||
return true;
|
||||
}),
|
||||
@@ -201,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await updateUser(ctx.user.id, {
|
||||
await updateUser(ctx.user.ownerId, {
|
||||
sshPrivateKey: input.sshPrivateKey,
|
||||
});
|
||||
|
||||
@@ -213,11 +223,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
const user = await updateUser(ctx.user.id, {
|
||||
const user = await updateUser(ctx.user.ownerId, {
|
||||
host: input.host,
|
||||
...(input.letsEncryptEmail && {
|
||||
letsEncryptEmail: input.letsEncryptEmail,
|
||||
}),
|
||||
letsEncryptEmail: input.letsEncryptEmail,
|
||||
certificateType: input.certificateType,
|
||||
https: input.https,
|
||||
});
|
||||
@@ -240,7 +248,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await updateUser(ctx.user.id, {
|
||||
await updateUser(ctx.user.ownerId, {
|
||||
sshPrivateKey: null,
|
||||
});
|
||||
return true;
|
||||
@@ -281,9 +289,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
await cleanUpUnusedImages(server.serverId);
|
||||
await cleanUpDockerBuilder(server.serverId);
|
||||
await cleanUpSystemPrune(server.serverId);
|
||||
|
||||
await cleanupAll(server.serverId);
|
||||
|
||||
await sendDockerCleanupNotifications(server.organizationId);
|
||||
});
|
||||
}
|
||||
@@ -300,7 +308,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
} else if (!IS_CLOUD) {
|
||||
const userUpdated = await updateUser(ctx.user.id, {
|
||||
const userUpdated = await updateUser(ctx.user.ownerId, {
|
||||
enableDockerCleanup: input.enableDockerCleanup,
|
||||
});
|
||||
|
||||
@@ -309,9 +317,9 @@ export const settingsRouter = createTRPCRouter({
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
);
|
||||
await cleanUpUnusedImages();
|
||||
await cleanUpDockerBuilder();
|
||||
await cleanUpSystemPrune();
|
||||
|
||||
await cleanupAll();
|
||||
|
||||
await sendDockerCleanupNotifications(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -810,6 +818,19 @@ export const settingsRouter = createTRPCRouter({
|
||||
"dokploy-traefik",
|
||||
input?.serverId,
|
||||
);
|
||||
|
||||
for (const port of input.additionalPorts) {
|
||||
const portCheck = await checkPortInUse(
|
||||
port.publishedPort,
|
||||
input.serverId,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const preparedEnv = prepareEnvironmentVariables(env);
|
||||
|
||||
await writeTraefikSetup({
|
||||
|
||||
@@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
const items = getStripeItems(input.serverQuantity, input.isAnnual);
|
||||
const user = await findUserById(ctx.user.id);
|
||||
// Always operate on the organization owner's Stripe customer
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
|
||||
let stripeCustomerId = user.stripeCustomerId;
|
||||
let stripeCustomerId = owner.stripeCustomerId;
|
||||
|
||||
if (stripeCustomerId) {
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
|
||||
if (customer.deleted) {
|
||||
await updateUser(user.id, {
|
||||
await updateUser(owner.id, {
|
||||
stripeCustomerId: null,
|
||||
});
|
||||
stripeCustomerId = null;
|
||||
@@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({
|
||||
customer: stripeCustomerId,
|
||||
}),
|
||||
metadata: {
|
||||
adminId: user.id,
|
||||
adminId: owner.id,
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||
@@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({
|
||||
return { sessionId: session.id };
|
||||
}),
|
||||
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.id);
|
||||
// Use the organization's owner account for billing portal
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
if (!owner.stripeCustomerId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Stripe Customer ID not found",
|
||||
});
|
||||
}
|
||||
const stripeCustomerId = user.stripeCustomerId;
|
||||
const stripeCustomerId = owner.stripeCustomerId;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createApiKey,
|
||||
findAdmin,
|
||||
findNotificationById,
|
||||
findOrganizationById,
|
||||
findUserById,
|
||||
@@ -87,7 +86,11 @@ export const userRouter = createTRPCRouter({
|
||||
// Allow access if:
|
||||
// 1. User is requesting their own information
|
||||
// 2. User has owner role (admin permissions) AND user is in the same organization
|
||||
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
|
||||
if (
|
||||
memberResult.userId !== ctx.user.id &&
|
||||
ctx.user.role !== "owner" &&
|
||||
ctx.user.role !== "admin"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this user",
|
||||
@@ -223,10 +226,61 @@ export const userRouter = createTRPCRouter({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ensure the acting user has admin privileges in the active organization
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owners or admins can delete users",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch target member within the active organization
|
||||
const targetMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, input.userId),
|
||||
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
|
||||
),
|
||||
});
|
||||
|
||||
if (!targetMember) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Target user is not a member of this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Never allow deleting the organization owner via this endpoint
|
||||
if (targetMember.role === "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot delete the organization owner",
|
||||
});
|
||||
}
|
||||
|
||||
// Admin self-protection: an admin cannot delete themselves
|
||||
if (targetMember.role === "admin" && input.userId === ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Admins cannot delete themselves. Ask the owner or another admin.",
|
||||
});
|
||||
}
|
||||
|
||||
// Only owners can delete admins
|
||||
// Admins can only delete members
|
||||
if (ctx.user.role === "admin" && targetMember.role === "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Only the organization owner can delete admins. Admins can only delete members.",
|
||||
});
|
||||
}
|
||||
|
||||
return await removeUserById(input.userId);
|
||||
}),
|
||||
assignPermissions: adminProcedure
|
||||
@@ -376,6 +430,23 @@ export const userRouter = createTRPCRouter({
|
||||
createApiKey: protectedProcedure
|
||||
.input(apiCreateApiKey)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Verify user is a member of the organization specified in metadata
|
||||
if (input.metadata?.organizationId) {
|
||||
const userMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.organizationId, input.metadata.organizationId),
|
||||
eq(member.userId, ctx.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this organization",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = await createApiKey(ctx.user.id, input);
|
||||
return apiKey;
|
||||
}),
|
||||
@@ -386,7 +457,35 @@ export const userRouter = createTRPCRouter({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Users can check their own organizations
|
||||
// Admins and owners can check organizations of members in their active organization
|
||||
if (input.userId !== ctx.user.id) {
|
||||
// Verify the target user is a member of the active organization
|
||||
const targetMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, input.userId),
|
||||
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
|
||||
),
|
||||
});
|
||||
|
||||
if (!targetMember) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User is not a member of your active organization",
|
||||
});
|
||||
}
|
||||
|
||||
// Only admins and owners can check other users' organizations
|
||||
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"Only admins and owners can check other users' organizations",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = await db.query.member.findMany({
|
||||
where: eq(member.userId, input.userId),
|
||||
});
|
||||
|
||||
@@ -183,7 +183,11 @@ export const uploadProcedure = async (opts: any) => {
|
||||
};
|
||||
|
||||
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
|
||||
if (
|
||||
!ctx.session ||
|
||||
!ctx.user ||
|
||||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
|
||||
) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
@@ -197,7 +201,11 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
});
|
||||
|
||||
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
|
||||
if (
|
||||
!ctx.session ||
|
||||
!ctx.user ||
|
||||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
|
||||
) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
createDefaultServerTraefikConfig,
|
||||
createDefaultTraefikConfig,
|
||||
IS_CLOUD,
|
||||
initCancelDeployments,
|
||||
initCronJobs,
|
||||
initializeNetwork,
|
||||
initSchedules,
|
||||
initVolumeBackupsCronJobs,
|
||||
initCancelDeployments,
|
||||
sendDokployRestartNotifications,
|
||||
setupDirectories,
|
||||
} from "@dokploy/server";
|
||||
@@ -26,6 +26,16 @@ config({ path: ".env" });
|
||||
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Initialize critical directories and Traefik config BEFORE Next.js starts
|
||||
// This prevents race conditions with the install script
|
||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||
setupDirectories();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
console.log("✅ Critical initialization complete");
|
||||
}
|
||||
|
||||
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
||||
const handle = app.getRequestHandler();
|
||||
void app.prepare().then(async () => {
|
||||
@@ -45,11 +55,8 @@ void app.prepare().then(async () => {
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||
setupDirectories();
|
||||
createDefaultMiddlewares();
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await migration();
|
||||
await initCronJobs();
|
||||
await initSchedules();
|
||||
|
||||
@@ -19,6 +19,7 @@ type QueueJob =
|
||||
type: "schedule";
|
||||
cronSchedule: string;
|
||||
scheduleId: string;
|
||||
timezone?: string | null;
|
||||
}
|
||||
| {
|
||||
type: "volume-backup";
|
||||
|
||||
@@ -71,7 +71,9 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
const command = search
|
||||
? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"`
|
||||
: baseCommand;
|
||||
client.exec(command, (err, stream) => {
|
||||
// Use pty: true to ensure the remote process receives SIGHUP when SSH connection closes
|
||||
// This is crucial for terminating docker logs processes when the connection is closed
|
||||
client.exec(command, { pty: true }, (err, stream) => {
|
||||
if (err) {
|
||||
console.error("Execution error:", err);
|
||||
ws.close();
|
||||
|
||||
@@ -58,7 +58,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
`docker exec -it -w / ${containerId} ${activeWay}`,
|
||||
{ pty: true },
|
||||
(err, stream) => {
|
||||
if (err) throw err;
|
||||
if (err) {
|
||||
console.error("SSH exec error:", err);
|
||||
ws.close();
|
||||
conn.end();
|
||||
return;
|
||||
}
|
||||
|
||||
stream
|
||||
.on("close", (code: number, _signal: string) => {
|
||||
@@ -93,10 +98,20 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
|
||||
ws.on("close", () => {
|
||||
stream.end();
|
||||
// Ensure SSH connection is closed when WebSocket closes
|
||||
conn.end();
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close();
|
||||
}
|
||||
conn.end();
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
|
||||
@@ -31,8 +31,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
// Generate unique connection ID for tracking
|
||||
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
if (!logPath) {
|
||||
console.log("logPath no provided");
|
||||
console.log(`[${connectionId}] logPath no provided`);
|
||||
ws.close(4000, "logPath no provided");
|
||||
return;
|
||||
}
|
||||
@@ -42,40 +45,55 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
let tailProcess: ReturnType<typeof spawn> | null = null;
|
||||
let sshClient: Client | null = null;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
if (!server.sshKeyId) return;
|
||||
const client = new Client();
|
||||
client
|
||||
if (!server.sshKeyId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
sshClient = new Client();
|
||||
sshClient
|
||||
.on("ready", () => {
|
||||
const command = `
|
||||
tail -n +1 -f ${logPath};
|
||||
`;
|
||||
client.exec(command, (err, stream) => {
|
||||
sshClient!.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
console.error("Execution error:", err);
|
||||
sshClient!.end();
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
stream
|
||||
.on("close", () => {
|
||||
client.end();
|
||||
sshClient!.end();
|
||||
ws.close();
|
||||
})
|
||||
.on("data", (data: string) => {
|
||||
ws.send(data.toString());
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
})
|
||||
.stderr.on("data", (data) => {
|
||||
ws.send(data.toString());
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(`SSH error: ${err.message}`);
|
||||
ws.close();
|
||||
}
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
@@ -85,26 +103,75 @@ export const setupDeploymentLogsWebSocketServer = (
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
client.end();
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const tail = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||
|
||||
tail.stdout.on("data", (data) => {
|
||||
ws.send(data.toString());
|
||||
});
|
||||
const stdout = tailProcess.stdout;
|
||||
const stderr = tailProcess.stderr;
|
||||
|
||||
tail.stderr.on("data", (data) => {
|
||||
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
||||
});
|
||||
tail.on("close", () => {
|
||||
if (stdout) {
|
||||
stdout.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(data.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
stderr.on("data", (data) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tailProcess.on("close", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
tailProcess.on("error", () => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGTERM");
|
||||
// Force kill after a timeout if it doesn't terminate
|
||||
setTimeout(() => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGKILL");
|
||||
} else {
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up resources on error
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (tailProcess && !tailProcess.killed) {
|
||||
tailProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
if (sshClient) {
|
||||
sshClient.end();
|
||||
}
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
// @ts-ignore
|
||||
const errorMessage = error?.message as unknown as string;
|
||||
ws.send(errorMessage || "An error occurred");
|
||||
ws.close();
|
||||
}
|
||||
} catch {
|
||||
// @ts-ignore
|
||||
// const errorMessage = error?.message as unknown as string;
|
||||
// ws.send(errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -30,7 +31,6 @@
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
|
||||
@@ -40,6 +40,7 @@ export const scheduleJob = (job: QueueJob) => {
|
||||
jobQueue.add(job.scheduleId, job, {
|
||||
repeat: {
|
||||
pattern: job.cronSchedule,
|
||||
tz: job.timezone || "UTC",
|
||||
},
|
||||
});
|
||||
} else if (job.type === "volume-backup") {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
||||
cronSchedule: z.string(),
|
||||
type: z.literal("schedule"),
|
||||
scheduleId: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
cronSchedule: z.string(),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
cleanUpDockerBuilder,
|
||||
cleanUpSystemPrune,
|
||||
cleanUpUnusedImages,
|
||||
cleanupAll,
|
||||
findBackupById,
|
||||
findScheduleById,
|
||||
findServerById,
|
||||
@@ -91,9 +89,7 @@ export const runJobs = async (job: QueueJob) => {
|
||||
logger.info("Server is inactive");
|
||||
return;
|
||||
}
|
||||
await cleanUpUnusedImages(serverId);
|
||||
await cleanUpDockerBuilder(serverId);
|
||||
await cleanUpSystemPrune(serverId);
|
||||
await cleanupAll(serverId);
|
||||
} else if (job.type === "schedule") {
|
||||
const { scheduleId } = job;
|
||||
const schedule = await findScheduleById(scheduleId);
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"switch:prod": "node scripts/switchToDist.js",
|
||||
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
|
||||
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dbml:generate": "npx tsx src/db/schema/dbml.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
|
||||
1200
packages/server/schema.dbml
Normal file
1200
packages/server/schema.dbml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -187,6 +187,12 @@ export const applications = pgTable("application", {
|
||||
registryId: text("registryId").references(() => registry.registryId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
rollbackRegistryId: text("rollbackRegistryId").references(
|
||||
() => registry.registryId,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -270,6 +276,11 @@ export const applicationsRelations = relations(
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
previewDeployments: many(previewDeployments),
|
||||
rollbackRegistry: one(registry, {
|
||||
fields: [applications.rollbackRegistryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -12,6 +19,7 @@ export const notificationType = pgEnum("notificationType", [
|
||||
"email",
|
||||
"gotify",
|
||||
"ntfy",
|
||||
"custom",
|
||||
"lark",
|
||||
]);
|
||||
|
||||
@@ -24,6 +32,7 @@ export const notifications = pgTable("notification", {
|
||||
appDeploy: boolean("appDeploy").notNull().default(false),
|
||||
appBuildError: boolean("appBuildError").notNull().default(false),
|
||||
databaseBackup: boolean("databaseBackup").notNull().default(false),
|
||||
volumeBackup: boolean("volumeBackup").notNull().default(false),
|
||||
dokployRestart: boolean("dokployRestart").notNull().default(false),
|
||||
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
|
||||
serverThreshold: boolean("serverThreshold").notNull().default(false),
|
||||
@@ -49,6 +58,9 @@ export const notifications = pgTable("notification", {
|
||||
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
customId: text("customId").references(() => custom.customId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
larkId: text("larkId").references(() => lark.larkId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
@@ -120,6 +132,15 @@ export const ntfy = pgTable("ntfy", {
|
||||
priority: integer("priority").notNull().default(3),
|
||||
});
|
||||
|
||||
export const custom = pgTable("custom", {
|
||||
customId: text("customId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
endpoint: text("endpoint").notNull(),
|
||||
headers: jsonb("headers").$type<Record<string, string>>(),
|
||||
});
|
||||
|
||||
export const lark = pgTable("lark", {
|
||||
larkId: text("larkId")
|
||||
.notNull()
|
||||
@@ -153,6 +174,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
fields: [notifications.ntfyId],
|
||||
references: [ntfy.ntfyId],
|
||||
}),
|
||||
custom: one(custom, {
|
||||
fields: [notifications.customId],
|
||||
references: [custom.customId],
|
||||
}),
|
||||
lark: one(lark, {
|
||||
fields: [notifications.larkId],
|
||||
references: [lark.larkId],
|
||||
@@ -169,6 +194,7 @@ export const apiCreateSlack = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -196,6 +222,7 @@ export const apiCreateTelegram = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -225,6 +252,7 @@ export const apiCreateDiscord = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -255,6 +283,7 @@ export const apiCreateEmail = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -290,6 +319,7 @@ export const apiCreateGotify = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -323,6 +353,7 @@ export const apiCreateNtfy = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
volumeBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
@@ -355,6 +386,32 @@ export const apiFindOneNotification = notificationsSchema
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiCreateCustom = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
databaseBackup: true,
|
||||
dokployRestart: true,
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
endpoint: z.string().min(1),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateCustom = apiCreateCustom.partial().extend({
|
||||
notificationId: z.string().min(1),
|
||||
customId: z.string().min(1),
|
||||
organizationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiTestCustomConnection = z.object({
|
||||
endpoint: z.string().min(1),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const apiCreateLark = notificationsSchema
|
||||
.pick({
|
||||
appBuildError: true,
|
||||
@@ -397,5 +454,7 @@ export const apiSendTest = notificationsSchema
|
||||
appToken: z.string(),
|
||||
accessToken: z.string().optional(),
|
||||
priority: z.number(),
|
||||
endpoint: z.string(),
|
||||
headers: z.string(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -39,6 +39,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
||||
buildApplications: many(applications, {
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
rollbackApplications: many(applications, {
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(registry, {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const schedules = pgTable("schedule", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
timezone: text("timezone"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
|
||||
@@ -5,17 +5,24 @@ enum applicationStatus {
|
||||
error
|
||||
}
|
||||
|
||||
enum backupType {
|
||||
database
|
||||
compose
|
||||
}
|
||||
|
||||
enum buildType {
|
||||
dockerfile
|
||||
heroku_buildpacks
|
||||
paketo_buildpacks
|
||||
nixpacks
|
||||
static
|
||||
railpack
|
||||
}
|
||||
|
||||
enum certificateType {
|
||||
letsencrypt
|
||||
none
|
||||
custom
|
||||
}
|
||||
|
||||
enum composeType {
|
||||
@@ -28,6 +35,7 @@ enum databaseType {
|
||||
mariadb
|
||||
mysql
|
||||
mongo
|
||||
"web-server"
|
||||
}
|
||||
|
||||
enum deploymentStatus {
|
||||
@@ -61,6 +69,8 @@ enum notificationType {
|
||||
discord
|
||||
email
|
||||
gotify
|
||||
ntfy
|
||||
custom
|
||||
}
|
||||
|
||||
enum protocolType {
|
||||
@@ -68,14 +78,21 @@ enum protocolType {
|
||||
udp
|
||||
}
|
||||
|
||||
enum publishModeType {
|
||||
ingress
|
||||
host
|
||||
}
|
||||
|
||||
enum RegistryType {
|
||||
selfHosted
|
||||
cloud
|
||||
}
|
||||
|
||||
enum Roles {
|
||||
admin
|
||||
user
|
||||
enum scheduleType {
|
||||
application
|
||||
compose
|
||||
server
|
||||
"dokploy-server"
|
||||
}
|
||||
|
||||
enum serverStatus {
|
||||
@@ -93,6 +110,11 @@ enum serviceType {
|
||||
compose
|
||||
}
|
||||
|
||||
enum shellType {
|
||||
bash
|
||||
sh
|
||||
}
|
||||
|
||||
enum sourceType {
|
||||
docker
|
||||
git
|
||||
@@ -112,6 +134,11 @@ enum sourceTypeCompose {
|
||||
raw
|
||||
}
|
||||
|
||||
enum triggerType {
|
||||
push
|
||||
tag
|
||||
}
|
||||
|
||||
table account {
|
||||
id text [pk, not null]
|
||||
account_id text [not null]
|
||||
@@ -133,7 +160,39 @@ table account {
|
||||
confirmationExpiresAt text
|
||||
}
|
||||
|
||||
table admin {
|
||||
table ai {
|
||||
aiId text [pk, not null]
|
||||
name text [not null]
|
||||
apiUrl text [not null]
|
||||
apiKey text [not null]
|
||||
model text [not null]
|
||||
isEnabled boolean [not null, default: true]
|
||||
organizationId text [not null]
|
||||
createdAt text [not null]
|
||||
}
|
||||
|
||||
table apikey {
|
||||
id text [pk, not null]
|
||||
name text
|
||||
start text
|
||||
prefix text
|
||||
key text [not null]
|
||||
user_id text [not null]
|
||||
refill_interval integer
|
||||
refill_amount integer
|
||||
last_refill_at timestamp
|
||||
enabled boolean
|
||||
rate_limit_enabled boolean
|
||||
rate_limit_time_window integer
|
||||
rate_limit_max integer
|
||||
request_count integer
|
||||
remaining integer
|
||||
last_request timestamp
|
||||
expires_at timestamp
|
||||
created_at timestamp [not null]
|
||||
updated_at timestamp [not null]
|
||||
permissions text
|
||||
metadata text
|
||||
}
|
||||
|
||||
table application {
|
||||
@@ -143,14 +202,19 @@ table application {
|
||||
description text
|
||||
env text
|
||||
previewEnv text
|
||||
watchPaths text[]
|
||||
previewBuildArgs text
|
||||
previewLabels text[]
|
||||
previewWildcard text
|
||||
previewPort integer [default: 3000]
|
||||
previewHttps boolean [not null, default: false]
|
||||
previewPath text [default: '/']
|
||||
certificateType certificateType [not null, default: 'none']
|
||||
previewCustomCertResolver text
|
||||
previewLimit integer [default: 3]
|
||||
isPreviewDeploymentsActive boolean [default: false]
|
||||
previewRequireCollaboratorPermissions boolean [default: true]
|
||||
rollbackActive boolean [default: false]
|
||||
buildArgs text
|
||||
memoryReservation text
|
||||
memoryLimit text
|
||||
@@ -167,6 +231,7 @@ table application {
|
||||
owner text
|
||||
branch text
|
||||
buildPath text [default: '/']
|
||||
triggerType triggerType [default: 'push']
|
||||
autoDeploy boolean
|
||||
gitlabProjectId integer
|
||||
gitlabRepository text
|
||||
@@ -174,6 +239,10 @@ table application {
|
||||
gitlabBranch text
|
||||
gitlabBuildPath text [default: '/']
|
||||
gitlabPathNamespace text
|
||||
giteaRepository text
|
||||
giteaOwner text
|
||||
giteaBranch text
|
||||
giteaBuildPath text [default: '/']
|
||||
bitbucketRepository text
|
||||
bitbucketOwner text
|
||||
bitbucketBranch text
|
||||
@@ -186,6 +255,7 @@ table application {
|
||||
customGitBranch text
|
||||
customGitBuildPath text
|
||||
customGitSSHKeyId text
|
||||
enableSubmodules boolean [not null, default: false]
|
||||
dockerfile text
|
||||
dockerContextPath text
|
||||
dockerBuildStage text
|
||||
@@ -201,52 +271,47 @@ table application {
|
||||
replicas integer [not null, default: 1]
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
buildType buildType [not null, default: 'nixpacks']
|
||||
railpackVersion text [default: '0.2.2']
|
||||
herokuVersion text [default: '24']
|
||||
publishDirectory text
|
||||
isStaticSpa boolean
|
||||
createdAt text [not null]
|
||||
registryId text
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
githubId text
|
||||
gitlabId text
|
||||
bitbucketId text
|
||||
giteaId text
|
||||
bitbucketId text
|
||||
serverId text
|
||||
}
|
||||
|
||||
table auth {
|
||||
id text [pk, not null]
|
||||
email text [not null, unique]
|
||||
password text [not null]
|
||||
rol Roles [not null]
|
||||
image text
|
||||
secret text
|
||||
token text
|
||||
is2FAEnabled boolean [not null, default: false]
|
||||
createdAt text [not null]
|
||||
resetPasswordToken text
|
||||
resetPasswordExpiresAt text
|
||||
confirmationToken text
|
||||
confirmationExpiresAt text
|
||||
}
|
||||
|
||||
table backup {
|
||||
backupId text [pk, not null]
|
||||
appName text [not null, unique]
|
||||
schedule text [not null]
|
||||
enabled boolean
|
||||
database text [not null]
|
||||
prefix text [not null]
|
||||
serviceName text
|
||||
destinationId text [not null]
|
||||
keepLatestCount integer
|
||||
backupType backupType [not null, default: 'database']
|
||||
databaseType databaseType [not null]
|
||||
composeId text
|
||||
postgresId text
|
||||
mariadbId text
|
||||
mysqlId text
|
||||
mongoId text
|
||||
userId text
|
||||
metadata jsonb
|
||||
}
|
||||
|
||||
table bitbucket {
|
||||
bitbucketId text [pk, not null]
|
||||
bitbucketUsername text
|
||||
bitbucketEmail text
|
||||
appPassword text
|
||||
apiToken text
|
||||
bitbucketWorkspaceName text
|
||||
gitProviderId text [not null]
|
||||
}
|
||||
@@ -258,7 +323,7 @@ table certificate {
|
||||
privateKey text [not null]
|
||||
certificatePath text [not null, unique]
|
||||
autoRenew boolean
|
||||
userId text
|
||||
organizationId text [not null]
|
||||
serverId text
|
||||
}
|
||||
|
||||
@@ -291,13 +356,17 @@ table compose {
|
||||
customGitBranch text
|
||||
customGitSSHKeyId text
|
||||
command text [not null, default: '']
|
||||
enableSubmodules boolean [not null, default: false]
|
||||
composePath text [not null, default: './docker-compose.yml']
|
||||
suffix text [not null, default: '']
|
||||
randomize boolean [not null, default: false]
|
||||
isolatedDeployment boolean [not null, default: false]
|
||||
isolatedDeploymentsVolume boolean [not null, default: false]
|
||||
triggerType triggerType [default: 'push']
|
||||
composeStatus applicationStatus [not null, default: 'idle']
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
createdAt text [not null]
|
||||
watchPaths text[]
|
||||
githubId text
|
||||
gitlabId text
|
||||
bitbucketId text
|
||||
@@ -305,19 +374,32 @@ table compose {
|
||||
serverId text
|
||||
}
|
||||
|
||||
table custom {
|
||||
customId text [pk, not null]
|
||||
endpoint text [not null]
|
||||
headers text
|
||||
}
|
||||
|
||||
table deployment {
|
||||
deploymentId text [pk, not null]
|
||||
title text [not null]
|
||||
description text
|
||||
status deploymentStatus [default: 'running']
|
||||
logPath text [not null]
|
||||
pid text
|
||||
applicationId text
|
||||
composeId text
|
||||
serverId text
|
||||
isPreviewDeployment boolean [default: false]
|
||||
previewDeploymentId text
|
||||
createdAt text [not null]
|
||||
startedAt text
|
||||
finishedAt text
|
||||
errorMessage text
|
||||
scheduleId text
|
||||
backupId text
|
||||
rollbackId text
|
||||
volumeBackupId text
|
||||
}
|
||||
|
||||
table destination {
|
||||
@@ -329,7 +411,8 @@ table destination {
|
||||
bucket text [not null]
|
||||
region text [not null]
|
||||
endpoint text [not null]
|
||||
userId text [not null]
|
||||
organizationId text [not null]
|
||||
createdAt timestamp [not null, default: `now()`]
|
||||
}
|
||||
|
||||
table discord {
|
||||
@@ -349,9 +432,12 @@ table domain {
|
||||
uniqueConfigKey serial [not null, increment]
|
||||
createdAt text [not null]
|
||||
composeId text
|
||||
customCertResolver text
|
||||
applicationId text
|
||||
previewDeploymentId text
|
||||
certificateType certificateType [not null, default: 'none']
|
||||
internalPath text [default: '/']
|
||||
stripPath boolean [not null, default: false]
|
||||
}
|
||||
|
||||
table email {
|
||||
@@ -364,12 +450,36 @@ table email {
|
||||
toAddress text[] [not null]
|
||||
}
|
||||
|
||||
table environment {
|
||||
environmentId text [pk, not null]
|
||||
name text [not null]
|
||||
description text
|
||||
createdAt text [not null]
|
||||
env text [not null, default: '']
|
||||
projectId text [not null]
|
||||
}
|
||||
|
||||
table git_provider {
|
||||
gitProviderId text [pk, not null]
|
||||
name text [not null]
|
||||
providerType gitProviderType [not null, default: 'github']
|
||||
createdAt text [not null]
|
||||
userId text
|
||||
organizationId text [not null]
|
||||
userId text [not null]
|
||||
}
|
||||
|
||||
table gitea {
|
||||
giteaId text [pk, not null]
|
||||
giteaUrl text [not null, default: 'https://gitea.com']
|
||||
redirect_uri text
|
||||
client_id text
|
||||
client_secret text
|
||||
gitProviderId text [not null]
|
||||
access_token text
|
||||
refresh_token text
|
||||
expires_at integer
|
||||
scopes text [default: 'repo,repo:status,read:user,read:org']
|
||||
last_authenticated_at integer
|
||||
}
|
||||
|
||||
table github {
|
||||
@@ -397,20 +507,6 @@ table gitlab {
|
||||
gitProviderId text [not null]
|
||||
}
|
||||
|
||||
table gitea {
|
||||
giteaId text [pk, not null]
|
||||
giteaUrl text [not null, default: 'https://gitea.com']
|
||||
redirect_uri text
|
||||
client_id text [not null]
|
||||
client_secret text [not null]
|
||||
access_token text
|
||||
refresh_token text
|
||||
expires_at integer
|
||||
gitProviderId text [not null]
|
||||
scopes text [default: 'repo,repo:status,read:user,read:org']
|
||||
last_authenticated_at integer
|
||||
}
|
||||
|
||||
table gotify {
|
||||
gotifyId text [pk, not null]
|
||||
serverUrl text [not null]
|
||||
@@ -427,6 +523,7 @@ table invitation {
|
||||
status text [not null]
|
||||
expires_at timestamp [not null]
|
||||
inviter_id text [not null]
|
||||
team_id text
|
||||
}
|
||||
|
||||
table mariadb {
|
||||
@@ -447,8 +544,17 @@ table mariadb {
|
||||
cpuLimit text
|
||||
externalPort integer
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
healthCheckSwarm json
|
||||
restartPolicySwarm json
|
||||
placementSwarm json
|
||||
updateConfigSwarm json
|
||||
rollbackConfigSwarm json
|
||||
modeSwarm json
|
||||
labelsSwarm json
|
||||
networkSwarm json
|
||||
replicas integer [not null, default: 1]
|
||||
createdAt text [not null]
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
serverId text
|
||||
}
|
||||
|
||||
@@ -458,6 +564,19 @@ table member {
|
||||
user_id text [not null]
|
||||
role text [not null]
|
||||
created_at timestamp [not null]
|
||||
team_id text
|
||||
canCreateProjects boolean [not null, default: false]
|
||||
canAccessToSSHKeys boolean [not null, default: false]
|
||||
canCreateServices boolean [not null, default: false]
|
||||
canDeleteProjects boolean [not null, default: false]
|
||||
canDeleteServices boolean [not null, default: false]
|
||||
canAccessToDocker boolean [not null, default: false]
|
||||
canAccessToAPI boolean [not null, default: false]
|
||||
canAccessToGitProviders boolean [not null, default: false]
|
||||
canAccessToTraefikFiles boolean [not null, default: false]
|
||||
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
|
||||
accessedEnvironments text[] [not null, default: `ARRAY[]::text[]`]
|
||||
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
|
||||
}
|
||||
|
||||
table mongo {
|
||||
@@ -476,8 +595,17 @@ table mongo {
|
||||
cpuLimit text
|
||||
externalPort integer
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
healthCheckSwarm json
|
||||
restartPolicySwarm json
|
||||
placementSwarm json
|
||||
updateConfigSwarm json
|
||||
rollbackConfigSwarm json
|
||||
modeSwarm json
|
||||
labelsSwarm json
|
||||
networkSwarm json
|
||||
replicas integer [not null, default: 1]
|
||||
createdAt text [not null]
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
serverId text
|
||||
replicaSets boolean [default: false]
|
||||
}
|
||||
@@ -518,8 +646,17 @@ table mysql {
|
||||
cpuLimit text
|
||||
externalPort integer
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
healthCheckSwarm json
|
||||
restartPolicySwarm json
|
||||
placementSwarm json
|
||||
updateConfigSwarm json
|
||||
rollbackConfigSwarm json
|
||||
modeSwarm json
|
||||
labelsSwarm json
|
||||
networkSwarm json
|
||||
replicas integer [not null, default: 1]
|
||||
createdAt text [not null]
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
serverId text
|
||||
}
|
||||
|
||||
@@ -539,7 +676,17 @@ table notification {
|
||||
discordId text
|
||||
emailId text
|
||||
gotifyId text
|
||||
userId text
|
||||
ntfyId text
|
||||
customId text
|
||||
organizationId text [not null]
|
||||
}
|
||||
|
||||
table ntfy {
|
||||
ntfyId text [pk, not null]
|
||||
serverUrl text [not null]
|
||||
topic text [not null]
|
||||
accessToken text [not null]
|
||||
priority integer [not null, default: 3]
|
||||
}
|
||||
|
||||
table organization {
|
||||
@@ -555,6 +702,7 @@ table organization {
|
||||
table port {
|
||||
portId text [pk, not null]
|
||||
publishedPort integer [not null]
|
||||
publishMode publishModeType [not null, default: 'host']
|
||||
targetPort integer [not null]
|
||||
protocol protocolType [not null]
|
||||
applicationId text [not null]
|
||||
@@ -577,8 +725,17 @@ table postgres {
|
||||
cpuReservation text
|
||||
cpuLimit text
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
healthCheckSwarm json
|
||||
restartPolicySwarm json
|
||||
placementSwarm json
|
||||
updateConfigSwarm json
|
||||
rollbackConfigSwarm json
|
||||
modeSwarm json
|
||||
labelsSwarm json
|
||||
networkSwarm json
|
||||
replicas integer [not null, default: 1]
|
||||
createdAt text [not null]
|
||||
projectId text [not null]
|
||||
environmentId text [not null]
|
||||
serverId text
|
||||
}
|
||||
|
||||
@@ -603,7 +760,7 @@ table project {
|
||||
name text [not null]
|
||||
description text
|
||||
createdAt text [not null]
|
||||
userId text [not null]
|
||||
organizationId text [not null]
|
||||
env text [not null, default: '']
|
||||
}
|
||||
|
||||
@@ -633,7 +790,16 @@ table redis {
|
||||
externalPort integer
|
||||
createdAt text [not null]
|
||||
applicationStatus applicationStatus [not null, default: 'idle']
|
||||
projectId text [not null]
|
||||
healthCheckSwarm json
|
||||
restartPolicySwarm json
|
||||
placementSwarm json
|
||||
updateConfigSwarm json
|
||||
rollbackConfigSwarm json
|
||||
modeSwarm json
|
||||
labelsSwarm json
|
||||
networkSwarm json
|
||||
replicas integer [not null, default: 1]
|
||||
environmentId text [not null]
|
||||
serverId text
|
||||
}
|
||||
|
||||
@@ -646,7 +812,34 @@ table registry {
|
||||
registryUrl text [not null, default: '']
|
||||
createdAt text [not null]
|
||||
selfHosted RegistryType [not null, default: 'cloud']
|
||||
userId text [not null]
|
||||
organizationId text [not null]
|
||||
}
|
||||
|
||||
table rollback {
|
||||
rollbackId text [pk, not null]
|
||||
deploymentId text [not null]
|
||||
version serial [not null, increment]
|
||||
image text
|
||||
createdAt text [not null]
|
||||
fullContext jsonb
|
||||
}
|
||||
|
||||
table schedule {
|
||||
scheduleId text [pk, not null]
|
||||
name text [not null]
|
||||
cronExpression text [not null]
|
||||
appName text [not null]
|
||||
serviceName text
|
||||
shellType shellType [not null, default: 'bash']
|
||||
scheduleType scheduleType [not null, default: 'application']
|
||||
command text [not null]
|
||||
script text
|
||||
applicationId text
|
||||
composeId text
|
||||
serverId text
|
||||
userId text
|
||||
enabled boolean [not null, default: true]
|
||||
createdAt text [not null]
|
||||
}
|
||||
|
||||
table security {
|
||||
@@ -671,14 +864,14 @@ table server {
|
||||
appName text [not null]
|
||||
enableDockerCleanup boolean [not null, default: false]
|
||||
createdAt text [not null]
|
||||
userId text [not null]
|
||||
organizationId text [not null]
|
||||
serverStatus serverStatus [not null, default: 'active']
|
||||
command text [not null, default: '']
|
||||
sshKeyId text
|
||||
metricsConfig jsonb [not null, default: `{"server":{"type":"Remote","refreshRate":60,"port":4500,"token":"","urlCallback":"","cronJob":"","retentionDays":2,"thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
||||
}
|
||||
|
||||
table session {
|
||||
table session_temp {
|
||||
id text [pk, not null]
|
||||
expires_at timestamp [not null]
|
||||
token text [not null, unique]
|
||||
@@ -705,49 +898,49 @@ table "ssh-key" {
|
||||
description text
|
||||
createdAt text [not null]
|
||||
lastUsedAt text
|
||||
userId text
|
||||
organizationId text [not null]
|
||||
}
|
||||
|
||||
table telegram {
|
||||
telegramId text [pk, not null]
|
||||
botToken text [not null]
|
||||
chatId text [not null]
|
||||
messageThreadId text
|
||||
}
|
||||
|
||||
table user {
|
||||
table two_factor {
|
||||
id text [pk, not null]
|
||||
secret text [not null]
|
||||
backup_codes text [not null]
|
||||
user_id text [not null]
|
||||
}
|
||||
|
||||
table user_temp {
|
||||
id text [pk, not null]
|
||||
name text [not null, default: '']
|
||||
token text [not null]
|
||||
isRegistered boolean [not null, default: false]
|
||||
expirationDate text [not null]
|
||||
createdAt text [not null]
|
||||
canCreateProjects boolean [not null, default: false]
|
||||
canAccessToSSHKeys boolean [not null, default: false]
|
||||
canCreateServices boolean [not null, default: false]
|
||||
canDeleteProjects boolean [not null, default: false]
|
||||
canDeleteServices boolean [not null, default: false]
|
||||
canAccessToDocker boolean [not null, default: false]
|
||||
canAccessToAPI boolean [not null, default: false]
|
||||
canAccessToGitProviders boolean [not null, default: false]
|
||||
canAccessToTraefikFiles boolean [not null, default: false]
|
||||
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
|
||||
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
|
||||
created_at timestamp [default: `now()`]
|
||||
two_factor_enabled boolean
|
||||
email text [not null, unique]
|
||||
email_verified boolean [not null]
|
||||
image text
|
||||
role text
|
||||
banned boolean
|
||||
ban_reason text
|
||||
ban_expires timestamp
|
||||
updated_at timestamp [not null]
|
||||
serverIp text
|
||||
certificateType certificateType [not null, default: 'none']
|
||||
https boolean [not null, default: false]
|
||||
host text
|
||||
letsEncryptEmail text
|
||||
sshPrivateKey text
|
||||
enableDockerCleanup boolean [not null, default: false]
|
||||
enableLogRotation boolean [not null, default: false]
|
||||
logCleanupCron text [default: '0 0 * * *']
|
||||
role text [not null, default: 'user']
|
||||
enablePaidFeatures boolean [not null, default: false]
|
||||
allowImpersonation boolean [not null, default: false]
|
||||
metricsConfig jsonb [not null, default: `{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
||||
cleanupCacheApplications boolean [not null, default: false]
|
||||
cleanupCacheOnPreviews boolean [not null, default: false]
|
||||
@@ -766,6 +959,29 @@ table verification {
|
||||
updated_at timestamp
|
||||
}
|
||||
|
||||
table volume_backup {
|
||||
volumeBackupId text [pk, not null]
|
||||
name text [not null]
|
||||
volumeName text [not null]
|
||||
prefix text [not null]
|
||||
serviceType serviceType [not null, default: 'application']
|
||||
appName text [not null]
|
||||
serviceName text
|
||||
turnOff boolean [not null, default: false]
|
||||
cronExpression text [not null]
|
||||
keepLatestCount integer
|
||||
enabled boolean
|
||||
applicationId text
|
||||
postgresId text
|
||||
mariadbId text
|
||||
mongoId text
|
||||
mysqlId text
|
||||
redisId text
|
||||
composeId text
|
||||
createdAt text [not null]
|
||||
destinationId text [not null]
|
||||
}
|
||||
|
||||
ref: mount.applicationId > application.applicationId
|
||||
|
||||
ref: mount.postgresId > postgres.postgresId
|
||||
@@ -780,7 +996,13 @@ ref: mount.redisId > redis.redisId
|
||||
|
||||
ref: mount.composeId > compose.composeId
|
||||
|
||||
ref: application.projectId > project.projectId
|
||||
ref: user_temp.id - account.user_id
|
||||
|
||||
ref: ai.organizationId - organization.id
|
||||
|
||||
ref: apikey.user_id > user_temp.id
|
||||
|
||||
ref: application.environmentId > environment.environmentId
|
||||
|
||||
ref: application.customGitSSHKeyId > "ssh-key".sshKeyId
|
||||
|
||||
@@ -790,6 +1012,8 @@ ref: application.githubId - github.githubId
|
||||
|
||||
ref: application.gitlabId - gitlab.gitlabId
|
||||
|
||||
ref: application.giteaId - gitea.giteaId
|
||||
|
||||
ref: application.bitbucketId - bitbucket.bitbucketId
|
||||
|
||||
ref: application.serverId > server.serverId
|
||||
@@ -804,13 +1028,17 @@ ref: backup.mysqlId > mysql.mysqlId
|
||||
|
||||
ref: backup.mongoId > mongo.mongoId
|
||||
|
||||
ref: backup.userId > user_temp.id
|
||||
|
||||
ref: backup.composeId > compose.composeId
|
||||
|
||||
ref: git_provider.gitProviderId - bitbucket.gitProviderId
|
||||
|
||||
ref: certificate.serverId > server.serverId
|
||||
|
||||
ref: certificate.userId - user.id
|
||||
ref: certificate.organizationId - organization.id
|
||||
|
||||
ref: compose.projectId > project.projectId
|
||||
ref: compose.environmentId > environment.environmentId
|
||||
|
||||
ref: compose.customGitSSHKeyId > "ssh-key".sshKeyId
|
||||
|
||||
@@ -820,6 +1048,8 @@ ref: compose.gitlabId - gitlab.gitlabId
|
||||
|
||||
ref: compose.bitbucketId - bitbucket.bitbucketId
|
||||
|
||||
ref: compose.giteaId - gitea.giteaId
|
||||
|
||||
ref: compose.serverId > server.serverId
|
||||
|
||||
ref: deployment.applicationId > application.applicationId
|
||||
@@ -830,7 +1060,15 @@ ref: deployment.serverId > server.serverId
|
||||
|
||||
ref: deployment.previewDeploymentId > preview_deployments.previewDeploymentId
|
||||
|
||||
ref: destination.userId - user.id
|
||||
ref: deployment.scheduleId > schedule.scheduleId
|
||||
|
||||
ref: deployment.backupId > backup.backupId
|
||||
|
||||
ref: rollback.deploymentId - deployment.deploymentId
|
||||
|
||||
ref: deployment.volumeBackupId > volume_backup.volumeBackupId
|
||||
|
||||
ref: destination.organizationId - organization.id
|
||||
|
||||
ref: domain.applicationId > application.applicationId
|
||||
|
||||
@@ -838,23 +1076,33 @@ ref: domain.composeId > compose.composeId
|
||||
|
||||
ref: preview_deployments.domainId - domain.domainId
|
||||
|
||||
ref: environment.projectId > project.projectId
|
||||
|
||||
ref: github.gitProviderId - git_provider.gitProviderId
|
||||
|
||||
ref: gitlab.gitProviderId - git_provider.gitProviderId
|
||||
|
||||
ref: gitea.gitProviderId - git_provider.gitProviderId
|
||||
|
||||
ref: git_provider.userId - user.id
|
||||
ref: git_provider.organizationId - organization.id
|
||||
|
||||
ref: mariadb.projectId > project.projectId
|
||||
ref: git_provider.userId - user_temp.id
|
||||
|
||||
ref: invitation.organization_id - organization.id
|
||||
|
||||
ref: mariadb.environmentId > environment.environmentId
|
||||
|
||||
ref: mariadb.serverId > server.serverId
|
||||
|
||||
ref: mongo.projectId > project.projectId
|
||||
ref: member.organization_id > organization.id
|
||||
|
||||
ref: member.user_id - user_temp.id
|
||||
|
||||
ref: mongo.environmentId > environment.environmentId
|
||||
|
||||
ref: mongo.serverId > server.serverId
|
||||
|
||||
ref: mysql.projectId > project.projectId
|
||||
ref: mysql.environmentId > environment.environmentId
|
||||
|
||||
ref: mysql.serverId > server.serverId
|
||||
|
||||
@@ -868,30 +1116,58 @@ ref: notification.emailId - email.emailId
|
||||
|
||||
ref: notification.gotifyId - gotify.gotifyId
|
||||
|
||||
ref: notification.userId - user.id
|
||||
ref: notification.ntfyId - ntfy.ntfyId
|
||||
|
||||
ref: notification.customId - custom.customId
|
||||
|
||||
ref: notification.organizationId - organization.id
|
||||
|
||||
ref: organization.owner_id > user_temp.id
|
||||
|
||||
ref: port.applicationId > application.applicationId
|
||||
|
||||
ref: postgres.projectId > project.projectId
|
||||
ref: postgres.environmentId > environment.environmentId
|
||||
|
||||
ref: postgres.serverId > server.serverId
|
||||
|
||||
ref: preview_deployments.applicationId > application.applicationId
|
||||
|
||||
ref: project.userId - user.id
|
||||
ref: project.organizationId > organization.id
|
||||
|
||||
ref: redirect.applicationId > application.applicationId
|
||||
|
||||
ref: redis.projectId > project.projectId
|
||||
ref: redis.environmentId > environment.environmentId
|
||||
|
||||
ref: redis.serverId > server.serverId
|
||||
|
||||
ref: registry.userId - user.id
|
||||
ref: schedule.applicationId - application.applicationId
|
||||
|
||||
ref: schedule.composeId > compose.composeId
|
||||
|
||||
ref: schedule.serverId > server.serverId
|
||||
|
||||
ref: schedule.userId > user_temp.id
|
||||
|
||||
ref: security.applicationId > application.applicationId
|
||||
|
||||
ref: server.userId - user.id
|
||||
|
||||
ref: server.sshKeyId > "ssh-key".sshKeyId
|
||||
|
||||
ref: "ssh-key".userId - user.id
|
||||
ref: server.organizationId > organization.id
|
||||
|
||||
ref: "ssh-key".organizationId - organization.id
|
||||
|
||||
ref: volume_backup.applicationId - application.applicationId
|
||||
|
||||
ref: volume_backup.postgresId - postgres.postgresId
|
||||
|
||||
ref: volume_backup.mariadbId - mariadb.mariadbId
|
||||
|
||||
ref: volume_backup.mongoId - mongo.mongoId
|
||||
|
||||
ref: volume_backup.mysqlId - mysql.mysqlId
|
||||
|
||||
ref: volume_backup.redisId - redis.redisId
|
||||
|
||||
ref: volume_backup.composeId - compose.composeId
|
||||
|
||||
ref: volume_backup.destinationId - destination.destinationId
|
||||
@@ -31,7 +31,8 @@ export const user = pgTable("user", {
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull().default(""),
|
||||
firstName: text("firstName").notNull().default(""),
|
||||
lastName: text("lastName").notNull().default(""),
|
||||
isRegistered: boolean("isRegistered").notNull().default(false),
|
||||
expirationDate: text("expirationDate")
|
||||
.notNull()
|
||||
@@ -56,7 +57,7 @@ export const user = pgTable("user", {
|
||||
host: text("host"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||
role: text("role").notNull().default("user"),
|
||||
// Metrics
|
||||
@@ -332,6 +333,7 @@ export const apiUpdateUser = createSchema.partial().extend({
|
||||
password: z.string().optional(),
|
||||
currentPassword: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
metricsConfig: z
|
||||
.object({
|
||||
server: z.object({
|
||||
|
||||
123
packages/server/src/emails/emails/volume-backup.tsx
Normal file
123
packages/server/src/emails/emails/volume-backup.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
applicationName: string;
|
||||
volumeName: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mongodb"
|
||||
| "mariadb"
|
||||
| "redis"
|
||||
| "compose";
|
||||
type: "error" | "success";
|
||||
errorMessage?: string;
|
||||
backupSize?: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const VolumeBackupEmail = ({
|
||||
projectName = "dokploy",
|
||||
applicationName = "frontend",
|
||||
volumeName = "app-data",
|
||||
serviceType = "application",
|
||||
type = "success",
|
||||
errorMessage,
|
||||
backupSize,
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Volume backup for ${applicationName} was ${type === "success" ? "successful ✅" : "failed ❌"}`;
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
Volume backup for <strong>{applicationName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Your volume backup for <strong>{applicationName}</strong> was{" "}
|
||||
{type === "success"
|
||||
? "successful ✅"
|
||||
: "failed. Please check the error message below. ❌"}
|
||||
.
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Project Name: <strong>{projectName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Application Name: <strong>{applicationName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Volume Name: <strong>{volumeName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Service Type: <strong>{serviceType}</strong>
|
||||
</Text>
|
||||
{backupSize && (
|
||||
<Text className="!leading-3">
|
||||
Backup Size: <strong>{backupSize}</strong>
|
||||
</Text>
|
||||
)}
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
{type === "error" && errorMessage ? (
|
||||
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Reason: </Text>
|
||||
<Text className="text-[12px] leading-[24px]">
|
||||
{errorMessage || "Error message not provided"}
|
||||
</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VolumeBackupEmail;
|
||||
@@ -132,11 +132,15 @@ const { handler, api } = betterAuth({
|
||||
const hutk = getHubSpotUTK(
|
||||
context?.request?.headers?.get("cookie") || undefined,
|
||||
);
|
||||
// Cast to include additional fields
|
||||
const userWithFields = user as typeof user & {
|
||||
lastName?: string;
|
||||
};
|
||||
const hubspotSuccess = await submitToHubSpot(
|
||||
{
|
||||
email: user.email,
|
||||
firstName: user.name,
|
||||
lastName: user.name,
|
||||
firstName: user.name || "", // name is mapped to firstName column
|
||||
lastName: userWithFields.lastName || "",
|
||||
},
|
||||
hutk,
|
||||
);
|
||||
@@ -204,6 +208,9 @@ const { handler, api } = betterAuth({
|
||||
},
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||
},
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
@@ -220,6 +227,12 @@ const { handler, api } = betterAuth({
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
@@ -316,16 +329,11 @@ export const validateRequest = async (request: IncomingMessage) => {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
image,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
twoFactorEnabled,
|
||||
} = apiKeyRecord.user;
|
||||
// When accessing from DB, use actual column names
|
||||
const userFromDb = apiKeyRecord.user as typeof apiKeyRecord.user & {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
session: {
|
||||
@@ -333,14 +341,14 @@ export const validateRequest = async (request: IncomingMessage) => {
|
||||
activeOrganizationId: organizationId || "",
|
||||
},
|
||||
user: {
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
image,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
twoFactorEnabled,
|
||||
id: userFromDb.id,
|
||||
name: userFromDb.firstName, // Map firstName back to name for better-auth
|
||||
email: userFromDb.email,
|
||||
emailVerified: userFromDb.emailVerified,
|
||||
image: userFromDb.image,
|
||||
createdAt: userFromDb.createdAt,
|
||||
updatedAt: userFromDb.updatedAt,
|
||||
twoFactorEnabled: userFromDb.twoFactorEnabled,
|
||||
role: member?.role || "member",
|
||||
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ export const isAdminPresent = async () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const findAdmin = async () => {
|
||||
export const findOwner = async () => {
|
||||
const admin = await db.query.member.findFirst({
|
||||
where: eq(member.role, "owner"),
|
||||
with: {
|
||||
@@ -107,11 +107,11 @@ export const getDokployUrl = async () => {
|
||||
if (IS_CLOUD) {
|
||||
return "https://app.dokploy.com";
|
||||
}
|
||||
const admin = await findAdmin();
|
||||
const owner = await findOwner();
|
||||
|
||||
if (admin.user.host) {
|
||||
const protocol = admin.user.https ? "https" : "http";
|
||||
return `${protocol}://${admin.user.host}`;
|
||||
if (owner.user.host) {
|
||||
const protocol = owner.user.https ? "https" : "http";
|
||||
return `${protocol}://${owner.user.host}`;
|
||||
}
|
||||
return `http://${admin.user.serverIp}:${process.env.PORT}`;
|
||||
return `http://${owner.user.serverIp}:${process.env.PORT}`;
|
||||
};
|
||||
|
||||
@@ -104,14 +104,28 @@ export const suggestVariants = async ({
|
||||
),
|
||||
}),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items).
|
||||
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
||||
|
||||
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
||||
|
||||
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
|
||||
- Generate different deployment VARIANTS of that SAME application
|
||||
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
|
||||
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
|
||||
- The name MUST include the specific application name the user mentioned
|
||||
|
||||
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
|
||||
- Suggest different open source projects that fulfill that need
|
||||
- Each suggestion should be a different tool/platform that solves the same problem
|
||||
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
||||
- The name should be the actual project name
|
||||
|
||||
Return your response as a JSON object with the following structure:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "project-slug",
|
||||
"name": "Project Name",
|
||||
"id": "project-or-variant-slug",
|
||||
"name": "Project Name or Variant Name",
|
||||
"shortDescription": "Brief one-line description",
|
||||
"description": "Detailed description"
|
||||
}
|
||||
@@ -120,10 +134,14 @@ export const suggestVariants = async ({
|
||||
|
||||
Important rules for the response:
|
||||
1. Use slug format for the id field (lowercase, hyphenated)
|
||||
2. The description field should ONLY contain a plain text description of the project, its features, and use cases
|
||||
3. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||
4. The shortDescription should be a single-line summary focusing on the main technologies
|
||||
5. All projects should be installable in docker and have docker compose support
|
||||
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
||||
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
||||
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
||||
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
||||
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||
8. All suggestions should be installable in docker and have docker compose support
|
||||
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||
|
||||
User wants to create a new project with the following details:
|
||||
|
||||
@@ -186,6 +204,24 @@ export const suggestVariants = async ({
|
||||
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
||||
7. Make sure all required services are defined in the docker-compose
|
||||
|
||||
Docker Image Rules (CRITICAL):
|
||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||
6. Examples of correct image usage:
|
||||
- image: sendingtk/chatwoot:develop
|
||||
- image: postgres:16-alpine
|
||||
- image: redis:7-alpine
|
||||
- image: chatwoot/chatwoot:latest
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
- build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
Volume Mounting and Configuration Rules:
|
||||
1. DO NOT create configuration files unless the service CANNOT work without them
|
||||
2. Most services can work with just environment variables - USE THEM FIRST
|
||||
@@ -214,6 +250,8 @@ export const suggestVariants = async ({
|
||||
- serviceName: the name of the service in the docker-compose
|
||||
2. Make sure the service is properly configured to work with the specified port
|
||||
|
||||
User's original request: ${input}
|
||||
|
||||
Project details:
|
||||
${suggestion?.description}
|
||||
`,
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
updatePreviewDeployment,
|
||||
} from "./preview-deployment";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
import { createRollback } from "./rollbacks";
|
||||
export type Application = typeof applications.$inferSelect;
|
||||
|
||||
export const createApplication = async (
|
||||
@@ -113,6 +112,7 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
server: true,
|
||||
previewDeployments: true,
|
||||
buildRegistry: true,
|
||||
rollbackRegistry: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
@@ -198,7 +198,7 @@ export const deployApplication = async ({
|
||||
command += await buildRemoteDocker(application);
|
||||
}
|
||||
|
||||
command += getBuildCommand(application);
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (serverId) {
|
||||
@@ -211,17 +211,6 @@ export const deployApplication = async ({
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateApplicationStatus(applicationId, "done");
|
||||
|
||||
if (application.rollbackActive) {
|
||||
const tagImage =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: application.appName;
|
||||
await createRollback({
|
||||
appName: tagImage || "",
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
await sendBuildSuccessNotifications({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
@@ -299,7 +288,7 @@ export const rebuildApplication = async ({
|
||||
try {
|
||||
let command = "set -e;";
|
||||
// Check case for docker only
|
||||
command += getBuildCommand(application);
|
||||
command += await getBuildCommand(application);
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, commandWithLog);
|
||||
@@ -310,17 +299,6 @@ export const rebuildApplication = async ({
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateApplicationStatus(applicationId, "done");
|
||||
|
||||
if (application.rollbackActive) {
|
||||
const tagImage =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: application.appName;
|
||||
await createRollback({
|
||||
appName: tagImage || "",
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
await sendBuildSuccessNotifications({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
@@ -423,6 +401,10 @@ export const deployPreviewApplication = async ({
|
||||
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.rollbackActive = false;
|
||||
application.buildRegistry = null;
|
||||
application.rollbackRegistry = null;
|
||||
application.registry = null;
|
||||
|
||||
let command = "set -e;";
|
||||
if (application.sourceType === "github") {
|
||||
@@ -431,7 +413,7 @@ export const deployPreviewApplication = async ({
|
||||
appName: previewDeployment.appName,
|
||||
branch: previewDeployment.branch,
|
||||
});
|
||||
command += getBuildCommand(application);
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (application.serverId) {
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateCustom,
|
||||
type apiCreateDiscord,
|
||||
type apiCreateEmail,
|
||||
type apiCreateLark,
|
||||
type apiCreateGotify,
|
||||
type apiCreateLark,
|
||||
type apiCreateNtfy,
|
||||
type apiCreateSlack,
|
||||
type apiCreateTelegram,
|
||||
type apiUpdateCustom,
|
||||
type apiUpdateDiscord,
|
||||
type apiUpdateEmail,
|
||||
type apiUpdateLark,
|
||||
type apiUpdateGotify,
|
||||
type apiUpdateLark,
|
||||
type apiUpdateNtfy,
|
||||
type apiUpdateSlack,
|
||||
type apiUpdateTelegram,
|
||||
custom,
|
||||
discord,
|
||||
email,
|
||||
lark,
|
||||
gotify,
|
||||
lark,
|
||||
notifications,
|
||||
ntfy,
|
||||
slack,
|
||||
@@ -57,6 +60,7 @@ export const createSlackNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "slack",
|
||||
@@ -88,6 +92,7 @@ export const updateSlackNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -148,6 +153,7 @@ export const createTelegramNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "telegram",
|
||||
@@ -179,6 +185,7 @@ export const updateTelegramNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -239,6 +246,7 @@ export const createDiscordNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "discord",
|
||||
@@ -270,6 +278,7 @@ export const updateDiscordNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -333,6 +342,7 @@ export const createEmailNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "email",
|
||||
@@ -364,6 +374,7 @@ export const updateEmailNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -429,6 +440,7 @@ export const createGotifyNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "gotify",
|
||||
@@ -459,6 +471,7 @@ export const updateGotifyNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -519,6 +532,7 @@ export const createNtfyNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "ntfy",
|
||||
@@ -549,6 +563,7 @@ export const updateNtfyNotification = async (
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
@@ -578,6 +593,94 @@ export const updateNtfyNotification = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createCustomNotification = async (
|
||||
input: typeof apiCreateCustom._type,
|
||||
organizationId: string,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const newCustom = await tx
|
||||
.insert(custom)
|
||||
.values({
|
||||
endpoint: input.endpoint,
|
||||
headers: input.headers,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newCustom) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error input: Inserting custom",
|
||||
});
|
||||
}
|
||||
|
||||
const newDestination = await tx
|
||||
.insert(notifications)
|
||||
.values({
|
||||
customId: newCustom.customId,
|
||||
name: input.name,
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
notificationType: "custom",
|
||||
organizationId: organizationId,
|
||||
serverThreshold: input.serverThreshold,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newDestination) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error input: Inserting notification",
|
||||
});
|
||||
}
|
||||
|
||||
return newDestination;
|
||||
});
|
||||
};
|
||||
|
||||
export const updateCustomNotification = async (
|
||||
input: typeof apiUpdateCustom._type,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const newDestination = await tx
|
||||
.update(notifications)
|
||||
.set({
|
||||
name: input.name,
|
||||
appDeploy: input.appDeploy,
|
||||
appBuildError: input.appBuildError,
|
||||
databaseBackup: input.databaseBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
organizationId: input.organizationId,
|
||||
serverThreshold: input.serverThreshold,
|
||||
})
|
||||
.where(eq(notifications.notificationId, input.notificationId))
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newDestination) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error Updating notification",
|
||||
});
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(custom)
|
||||
.set({
|
||||
endpoint: input.endpoint,
|
||||
headers: input.headers,
|
||||
})
|
||||
.where(eq(custom.customId, input.customId));
|
||||
|
||||
return newDestination;
|
||||
});
|
||||
};
|
||||
|
||||
export const findNotificationById = async (notificationId: string) => {
|
||||
const notification = await db.query.notifications.findFirst({
|
||||
where: eq(notifications.notificationId, notificationId),
|
||||
@@ -588,6 +691,7 @@ export const findNotificationById = async (notificationId: string) => {
|
||||
email: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@ export function getMountPath(dockerImage: string): string {
|
||||
if (versionMatch?.[1]) {
|
||||
const version = Number.parseInt(versionMatch[1], 10);
|
||||
if (version >= 18) {
|
||||
return `/var/lib/postgresql/${version}/data`;
|
||||
// PostgreSQL 18+ uses /var/lib/postgresql/{version}/docker as the default PGDATA
|
||||
return `/var/lib/postgresql/${version}/docker`;
|
||||
}
|
||||
}
|
||||
return "/var/lib/postgresql/data";
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
deployments as deploymentsSchema,
|
||||
rollbacks,
|
||||
} from "../db/schema";
|
||||
import { type ApplicationNested, getAuthConfig } from "../utils/builders";
|
||||
import type { ApplicationNested } from "../utils/builders";
|
||||
import { getRegistryTag } from "../utils/cluster/upload";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -22,11 +23,12 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const { fullContext, ...other } = input;
|
||||
const rollback = await tx
|
||||
.insert(rollbacks)
|
||||
@@ -70,9 +72,11 @@ export const createRollback = async (
|
||||
})
|
||||
.where(eq(deploymentsSchema.deploymentId, rollback.deploymentId));
|
||||
|
||||
await createRollbackImage(rest, tagImage);
|
||||
const updatedRollback = await tx.query.rollbacks.findFirst({
|
||||
where: eq(rollbacks.rollbackId, rollback.rollbackId),
|
||||
});
|
||||
|
||||
return rollback;
|
||||
return updatedRollback;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,27 +107,6 @@ export const findRollbackById = async (rollbackId: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const createRollbackImage = async (
|
||||
application: ApplicationNested,
|
||||
tagImage: string,
|
||||
) => {
|
||||
const docker = await getRemoteDocker(application.serverId);
|
||||
|
||||
const appTagName =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: `${application.appName}:latest`;
|
||||
|
||||
const result = docker.getImage(appTagName || "");
|
||||
|
||||
const [repo, version] = tagImage.split(":");
|
||||
|
||||
await result.tag({
|
||||
repo,
|
||||
tag: version,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
@@ -179,7 +162,6 @@ export const rollback = async (rollbackId: string) => {
|
||||
if (!result.fullContext) {
|
||||
throw new Error("Rollback context not found");
|
||||
}
|
||||
|
||||
// Use the full context for rollback
|
||||
await rollbackApplication(
|
||||
application.appName,
|
||||
@@ -199,6 +181,7 @@ const rollbackApplication = async (
|
||||
};
|
||||
mounts: Mount[];
|
||||
ports: Port[];
|
||||
rollbackRegistry?: Registry;
|
||||
},
|
||||
) => {
|
||||
if (!fullContext) {
|
||||
@@ -245,16 +228,24 @@ const rollbackApplication = async (
|
||||
fullContext.environment.project.env,
|
||||
);
|
||||
|
||||
// For rollback, we use the provided image instead of calculating it
|
||||
const authConfig = getAuthConfig(fullContext as ApplicationNested);
|
||||
// Build the full registry image path if rollbackRegistry is available
|
||||
// e.g., "appName:v5" -> "siumauricio/appName:v5" or "registry.com/prefix/appName:v5"
|
||||
let rollbackImage = image;
|
||||
if (fullContext.rollbackRegistry) {
|
||||
rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
|
||||
}
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
authconfig: authConfig,
|
||||
authconfig: {
|
||||
password: fullContext.rollbackRegistry?.password || "",
|
||||
username: fullContext.rollbackRegistry?.username || "",
|
||||
serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
|
||||
},
|
||||
Name: appName,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
HealthCheck,
|
||||
Image: image,
|
||||
Image: rollbackImage,
|
||||
Env: envVariables,
|
||||
Mounts: [...volumesMount, ...bindsMount],
|
||||
...(command
|
||||
@@ -297,7 +288,8 @@ const rollbackApplication = async (
|
||||
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await docker.createService(settings);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,38 +215,6 @@ echo "$json_output"
|
||||
return result;
|
||||
};
|
||||
|
||||
export const cleanupFullDocker = async (serverId?: string | null) => {
|
||||
const cleanupImages = "docker image prune --force";
|
||||
const cleanupVolumes = "docker volume prune --force";
|
||||
const cleanupContainers = "docker container prune --force";
|
||||
const cleanupSystem = "docker system prune --force --volumes";
|
||||
const cleanupBuilder = "docker builder prune --force";
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`
|
||||
${cleanupImages}
|
||||
${cleanupVolumes}
|
||||
${cleanupContainers}
|
||||
${cleanupSystem}
|
||||
${cleanupBuilder}
|
||||
`,
|
||||
);
|
||||
}
|
||||
await execAsync(`
|
||||
${cleanupImages}
|
||||
${cleanupVolumes}
|
||||
${cleanupContainers}
|
||||
${cleanupSystem}
|
||||
${cleanupBuilder}
|
||||
`);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getDockerResourceType = async (
|
||||
resourceName: string,
|
||||
serverId?: string,
|
||||
@@ -372,19 +340,27 @@ export const readPorts = async (
|
||||
publishedPort: number;
|
||||
protocol?: string;
|
||||
}[] = [];
|
||||
const seenPorts = new Set<string>();
|
||||
for (const key in parsedResult) {
|
||||
if (Object.hasOwn(parsedResult, key)) {
|
||||
const containerPortMapppings = parsedResult[key];
|
||||
const protocol = key.split("/")[1];
|
||||
const targetPort = Number.parseInt(key.split("/")[0] ?? "0", 10);
|
||||
|
||||
containerPortMapppings.forEach((mapping: any) => {
|
||||
ports.push({
|
||||
targetPort: targetPort,
|
||||
publishedPort: Number.parseInt(mapping.HostPort, 10),
|
||||
protocol: protocol,
|
||||
});
|
||||
});
|
||||
// Take only the first mapping to avoid duplicates (IPv4 and IPv6)
|
||||
const firstMapping = containerPortMapppings[0];
|
||||
if (firstMapping) {
|
||||
const publishedPort = Number.parseInt(firstMapping.HostPort, 10);
|
||||
const portKey = `${targetPort}-${publishedPort}-${protocol}`;
|
||||
if (!seenPorts.has(portKey)) {
|
||||
seenPorts.add(portKey);
|
||||
ports.push({
|
||||
targetPort: targetPort,
|
||||
publishedPort: publishedPort,
|
||||
protocol: protocol,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ports.filter(
|
||||
@@ -392,6 +368,28 @@ export const readPorts = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const checkPortInUse = async (
|
||||
port: number,
|
||||
serverId?: string,
|
||||
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
||||
try {
|
||||
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
const container = stdout.trim();
|
||||
|
||||
return {
|
||||
isInUse: !!container,
|
||||
conflictingContainer: container || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking port availability:", error);
|
||||
return { isInUse: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const writeTraefikSetup = async (input: TraefikOptions) => {
|
||||
const resourceType = await getDockerResourceType(
|
||||
"dokploy-traefik",
|
||||
|
||||
@@ -12,13 +12,69 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
const volumeBackup = await db.query.volumeBackups.findFirst({
|
||||
where: eq(volumeBackups.volumeBackupId, volumeBackupId),
|
||||
with: {
|
||||
application: true,
|
||||
postgres: true,
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ export const serverSetup = async (
|
||||
export const defaultCommand = (isBuildServer = false) => {
|
||||
const bashCommand = `
|
||||
set -e;
|
||||
DOCKER_VERSION=27.0.3
|
||||
DOCKER_VERSION=28.5.0
|
||||
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
|
||||
SYS_ARCH=$(uname -m)
|
||||
CURRENT_USER=$USER
|
||||
@@ -524,7 +524,7 @@ if ! [ -x "$(command -v docker)" ]; then
|
||||
echo "Please install Docker manually."
|
||||
exit 1
|
||||
fi
|
||||
curl -s https://releases.rancher.com/install-docker/$DOCKER_VERSION.sh | sh 2>&1
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
curl -s https://get.docker.com | sh -s -- --version $DOCKER_VERSION 2>&1
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { findAdmin } from "@dokploy/server/services/admin";
|
||||
import { findOwner } from "@dokploy/server/services/admin";
|
||||
import { updateUser } from "@dokploy/server/services/user";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
@@ -29,9 +29,9 @@ export const startLogCleanup = async (
|
||||
}
|
||||
});
|
||||
|
||||
const admin = await findAdmin();
|
||||
if (admin) {
|
||||
await updateUser(admin.user.id, {
|
||||
const owner = await findOwner();
|
||||
if (owner) {
|
||||
await updateUser(owner.user.id, {
|
||||
logCleanupCron: cronExpression,
|
||||
});
|
||||
}
|
||||
@@ -51,9 +51,9 @@ export const stopLogCleanup = async (): Promise<boolean> => {
|
||||
}
|
||||
|
||||
// Update database
|
||||
const admin = await findAdmin();
|
||||
if (admin) {
|
||||
await updateUser(admin.user.id, {
|
||||
const owner = await findOwner();
|
||||
if (owner) {
|
||||
await updateUser(owner.user.id, {
|
||||
logCleanupCron: null,
|
||||
});
|
||||
}
|
||||
@@ -69,8 +69,8 @@ export const getLogCleanupStatus = async (): Promise<{
|
||||
enabled: boolean;
|
||||
cronExpression: string | null;
|
||||
}> => {
|
||||
const admin = await findAdmin();
|
||||
const cronExpression = admin?.user.logCleanupCron ?? null;
|
||||
const owner = await findOwner();
|
||||
const cronExpression = owner?.user.logCleanupCron ?? null;
|
||||
return {
|
||||
enabled: cronExpression !== null,
|
||||
cronExpression,
|
||||
|
||||
@@ -6,11 +6,7 @@ import { eq } from "drizzle-orm";
|
||||
import { scheduleJob } from "node-schedule";
|
||||
import { db } from "../../db/index";
|
||||
import { startLogCleanup } from "../access-log/handler";
|
||||
import {
|
||||
cleanUpDockerBuilder,
|
||||
cleanUpSystemPrune,
|
||||
cleanUpUnusedImages,
|
||||
} from "../docker/utils";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials, scheduleBackup } from "./utils";
|
||||
@@ -34,9 +30,9 @@ export const initCronJobs = async () => {
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
|
||||
);
|
||||
await cleanUpUnusedImages();
|
||||
await cleanUpDockerBuilder();
|
||||
await cleanUpSystemPrune();
|
||||
|
||||
await cleanupAll();
|
||||
|
||||
await sendDockerCleanupNotifications(admin.user.id);
|
||||
});
|
||||
}
|
||||
@@ -50,9 +46,9 @@ export const initCronJobs = async () => {
|
||||
console.log(
|
||||
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
|
||||
);
|
||||
await cleanUpUnusedImages(serverId);
|
||||
await cleanUpDockerBuilder(serverId);
|
||||
await cleanUpSystemPrune(serverId);
|
||||
|
||||
await cleanupAll(serverId);
|
||||
|
||||
await sendDockerCleanupNotifications(
|
||||
admin.user.id,
|
||||
`Docker cleanup for Server ${name} (${serverId})`,
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -62,16 +62,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
const { accessKey, secretAccessKey, region, endpoint, provider } =
|
||||
destination;
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id=${accessKey}`,
|
||||
`--s3-secret-access-key=${secretAccessKey}`,
|
||||
`--s3-region=${region}`,
|
||||
`--s3-endpoint=${endpoint}`,
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||
`--s3-region="${region}"`,
|
||||
`--s3-endpoint="${endpoint}"`,
|
||||
"--s3-no-check-bucket",
|
||||
"--s3-force-path-style",
|
||||
];
|
||||
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider=${provider}`);
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
}
|
||||
|
||||
return rcloneFlags;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getDockerContextPath,
|
||||
} from "../filesystem/directory";
|
||||
import type { ApplicationNested } from ".";
|
||||
import { createEnvFileCommand } from "./utils";
|
||||
|
||||
export const getDockerCommand = (application: ApplicationNested) => {
|
||||
const {
|
||||
@@ -68,21 +67,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
|
||||
commandArgs.push("--secret", `type=env,id=${key}`);
|
||||
}
|
||||
|
||||
/*
|
||||
Do not generate an environment file when publishDirectory is specified,
|
||||
as it could be publicly exposed.
|
||||
*/
|
||||
let command = "";
|
||||
if (!publishDirectory) {
|
||||
command += createEnvFileCommand(
|
||||
dockerFilePath,
|
||||
env,
|
||||
application.environment.project.env,
|
||||
application.environment.env,
|
||||
);
|
||||
}
|
||||
|
||||
command += `
|
||||
const command = `
|
||||
echo "Building ${appName}" ;
|
||||
cd ${dockerContextPath} || {
|
||||
echo "❌ The path ${dockerContextPath} does not exist" ;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { uploadImageRemoteCommand } from "../cluster/upload";
|
||||
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -30,39 +30,45 @@ export type ApplicationNested = InferResultType<
|
||||
ports: true;
|
||||
registry: true;
|
||||
buildRegistry: true;
|
||||
rollbackRegistry: true;
|
||||
deployments: true;
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
let command = "";
|
||||
const { buildType } = application;
|
||||
|
||||
if (application.sourceType === "docker") {
|
||||
return "";
|
||||
if (application.sourceType !== "docker") {
|
||||
const { buildType } = application;
|
||||
switch (buildType) {
|
||||
case "nixpacks":
|
||||
command = getNixpacksCommand(application);
|
||||
break;
|
||||
case "heroku_buildpacks":
|
||||
command = getHerokuCommand(application);
|
||||
break;
|
||||
case "paketo_buildpacks":
|
||||
command = getPaketoCommand(application);
|
||||
break;
|
||||
case "static":
|
||||
command = getStaticCommand(application);
|
||||
break;
|
||||
case "dockerfile":
|
||||
command = getDockerCommand(application);
|
||||
break;
|
||||
case "railpack":
|
||||
command = getRailpackCommand(application);
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (buildType) {
|
||||
case "nixpacks":
|
||||
command = getNixpacksCommand(application);
|
||||
break;
|
||||
case "heroku_buildpacks":
|
||||
command = getHerokuCommand(application);
|
||||
break;
|
||||
case "paketo_buildpacks":
|
||||
command = getPaketoCommand(application);
|
||||
break;
|
||||
case "static":
|
||||
command = getStaticCommand(application);
|
||||
break;
|
||||
case "dockerfile":
|
||||
command = getDockerCommand(application);
|
||||
break;
|
||||
case "railpack":
|
||||
command = getRailpackCommand(application);
|
||||
break;
|
||||
}
|
||||
if (application.registry || application.buildRegistry) {
|
||||
command += uploadImageRemoteCommand(application);
|
||||
|
||||
if (
|
||||
application.registry ||
|
||||
application.buildRegistry ||
|
||||
application.rollbackRegistry
|
||||
) {
|
||||
command += await uploadImageRemoteCommand(application);
|
||||
}
|
||||
|
||||
return command;
|
||||
@@ -188,17 +194,11 @@ const getImageName = (application: ApplicationNested) => {
|
||||
}
|
||||
|
||||
if (registry) {
|
||||
const { registryUrl, imagePrefix, username } = registry;
|
||||
const registryTag = imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
|
||||
const registryTag = getRegistryTag(registry, imageName);
|
||||
return registryTag;
|
||||
}
|
||||
if (buildRegistry) {
|
||||
const { registryUrl, imagePrefix, username } = buildRegistry;
|
||||
const registryTag = imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
|
||||
const registryTag = getRegistryTag(buildRegistry, imageName);
|
||||
return registryTag;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
|
||||
import type { Registry } from "@dokploy/server/services/registry";
|
||||
import { createRollback } from "@dokploy/server/services/rollbacks";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
export const uploadImageRemoteCommand = (application: ApplicationNested) => {
|
||||
export const uploadImageRemoteCommand = async (
|
||||
application: ApplicationNested,
|
||||
) => {
|
||||
const registry = application.registry;
|
||||
const buildRegistry = application.buildRegistry;
|
||||
const rollbackRegistry = application.rollbackRegistry;
|
||||
|
||||
if (!registry && !buildRegistry) {
|
||||
if (!registry && !buildRegistry && !rollbackRegistry) {
|
||||
throw new Error("No registry found");
|
||||
}
|
||||
|
||||
const { appName } = application;
|
||||
const imageName = `${appName}:latest`;
|
||||
const imageName =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage || ""
|
||||
: `${appName}:latest`;
|
||||
|
||||
const commands: string[] = [];
|
||||
if (registry) {
|
||||
@@ -35,16 +43,38 @@ export const uploadImageRemoteCommand = (application: ApplicationNested) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rollbackRegistry && application.rollbackActive) {
|
||||
const deployment = await findAllDeploymentsByApplicationId(
|
||||
application.applicationId,
|
||||
);
|
||||
if (!deployment || !deployment[0]) {
|
||||
throw new Error("Deployment not found");
|
||||
}
|
||||
const deploymentId = deployment[0].deploymentId;
|
||||
const rollback = await createRollback({
|
||||
appName: appName,
|
||||
deploymentId: deploymentId,
|
||||
});
|
||||
|
||||
const rollbackRegistryTag = getRegistryTag(
|
||||
rollbackRegistry,
|
||||
rollback?.image || "",
|
||||
);
|
||||
if (rollbackRegistryTag) {
|
||||
commands.push(`echo "🔄 [Enabled Rollback Registry]"`);
|
||||
commands.push(
|
||||
getRegistryCommands(rollbackRegistry, imageName, rollbackRegistryTag),
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return commands.join("\n");
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const getRegistryTag = (registry: Registry | null, imageName: string) => {
|
||||
if (!registry) {
|
||||
return null;
|
||||
}
|
||||
export const getRegistryTag = (registry: Registry, imageName: string) => {
|
||||
const { registryUrl, imagePrefix, username } = registry;
|
||||
return imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
|
||||
@@ -144,81 +144,116 @@ export const getContainerByName = (name: string): Promise<ContainerInfo> => {
|
||||
});
|
||||
});
|
||||
};
|
||||
export const cleanUpUnusedImages = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker image prune --force";
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanStoppedContainers = async (serverId?: string) => {
|
||||
/**
|
||||
* Docker commands passed through this method are held during Docker's build or pull process.
|
||||
*
|
||||
* https://github.com/Dokploy/dokploy/pull/3064
|
||||
* https://github.com/fir4tozden
|
||||
*/
|
||||
export const dockerSafeExec = (exec: string) => `CHECK_INTERVAL=10
|
||||
|
||||
echo "Preparing for execution..."
|
||||
|
||||
while true; do
|
||||
PROCESSES=$(ps aux | grep -E "docker build|docker pull" | grep -v grep)
|
||||
|
||||
if [ -z "$PROCESSES" ]; then
|
||||
echo "Docker is idle. Starting execution..."
|
||||
break
|
||||
else
|
||||
echo "Docker is busy. Will check again in $CHECK_INTERVAL seconds..."
|
||||
sleep $CHECK_INTERVAL
|
||||
fi
|
||||
done
|
||||
|
||||
${exec}
|
||||
|
||||
echo "Execution completed."`;
|
||||
|
||||
export const cleanupContainers = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker container prune --force";
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
await execAsyncRemote(serverId, dockerSafeExec(command));
|
||||
} else {
|
||||
await execAsync(command);
|
||||
await execAsync(dockerSafeExec(command));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanUpUnusedVolumes = async (serverId?: string) => {
|
||||
export const cleanupImages = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker volume prune --force";
|
||||
const command = "docker image prune --all --force";
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
await execAsyncRemote(serverId, dockerSafeExec(command));
|
||||
} else await execAsync(dockerSafeExec(command));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupVolumes = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker volume prune --all --force";
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, dockerSafeExec(command));
|
||||
} else {
|
||||
await execAsync(command);
|
||||
await execAsync(dockerSafeExec(command));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanUpInactiveContainers = async () => {
|
||||
export const cleanupBuilders = async (serverId?: string) => {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const inactiveContainers = containers.filter(
|
||||
(container) => container.State !== "running",
|
||||
);
|
||||
const command = "docker builder prune --all --force";
|
||||
|
||||
for (const container of inactiveContainers) {
|
||||
await docker.getContainer(container.Id).remove({ force: true });
|
||||
console.log(`Cleaning up inactive container: ${container.Id}`);
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, dockerSafeExec(command));
|
||||
} else {
|
||||
await execAsync(dockerSafeExec(command));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up inactive containers:", error);
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanUpDockerBuilder = async (serverId?: string) => {
|
||||
const command = "docker builder prune --all --force";
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
export const cleanupSystem = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker system prune --all --force";
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, dockerSafeExec(command));
|
||||
} else {
|
||||
await execAsync(dockerSafeExec(command));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanUpSystemPrune = async (serverId?: string) => {
|
||||
const command = "docker system prune --force --volumes";
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
export const cleanupAll = async (serverId?: string) => {
|
||||
await cleanupContainers(serverId);
|
||||
await cleanupImages(serverId);
|
||||
await cleanupBuilders(serverId);
|
||||
await cleanupSystem(serverId);
|
||||
};
|
||||
|
||||
export const startService = async (appName: string) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renderAsync } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendGotifyNotification,
|
||||
@@ -45,12 +46,13 @@ export const sendBuildErrorNotifications = async ({
|
||||
slack: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const notification of notificationList) {
|
||||
const { email, discord, telegram, slack, gotify, ntfy, lark } =
|
||||
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
|
||||
notification;
|
||||
try {
|
||||
if (email) {
|
||||
@@ -220,6 +222,22 @@ export const sendBuildErrorNotifications = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await sendCustomNotification(custom, {
|
||||
title: "Build Error",
|
||||
message: "Build failed with errors",
|
||||
projectName,
|
||||
applicationName,
|
||||
applicationType,
|
||||
errorMessage,
|
||||
buildLink,
|
||||
timestamp: date.toISOString(),
|
||||
date: date.toLocaleString(),
|
||||
status: "error",
|
||||
type: "build",
|
||||
});
|
||||
}
|
||||
|
||||
if (lark) {
|
||||
const limitCharacter = 800;
|
||||
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user