mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-23 06:45:27 +02:00
Compare commits
225 Commits
2326-add-s
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ec77ade8 | ||
|
|
5f441f5b54 | ||
|
|
ed1e3244c6 | ||
|
|
6685bd618e | ||
|
|
f5d334244a | ||
|
|
fd084c6d37 | ||
|
|
d8514b067b | ||
|
|
0590e78854 | ||
|
|
27fa0e881a | ||
|
|
72f2cc6268 | ||
|
|
854bd88e0a | ||
|
|
acf385a1f3 | ||
|
|
d1bc109697 | ||
|
|
38c7e1e996 | ||
|
|
54d5266573 | ||
|
|
3a5ac9d31f | ||
|
|
0ddf6b851f | ||
|
|
eb4fbff1b2 | ||
|
|
3aeb52810c | ||
|
|
8eaf2ab5c7 | ||
|
|
5ebcbf86ea | ||
|
|
67f4ca2cd9 | ||
|
|
6bb5404f87 | ||
|
|
3e356e6890 | ||
|
|
72cc7a2d2c | ||
|
|
d875e08d48 | ||
|
|
0b45b795e8 | ||
|
|
d187b52e09 | ||
|
|
5f13679a97 | ||
|
|
415327c246 | ||
|
|
12b8f8a4fd | ||
|
|
fea3ec9a6f | ||
|
|
2976bb5cf7 | ||
|
|
092afbe1fa | ||
|
|
a32e7e0041 | ||
|
|
ee9edd7ff4 | ||
|
|
3799aeab74 | ||
|
|
4f6eb51c06 | ||
|
|
7cf898dcf6 | ||
|
|
1c83919408 | ||
|
|
b230687c8a | ||
|
|
b499cefebc | ||
|
|
a04a4c05ea | ||
|
|
e7dc05d031 | ||
|
|
9544b2ace3 | ||
|
|
85632fd0c2 | ||
|
|
31cdae1b72 | ||
|
|
702af64444 | ||
|
|
eef27b67c2 | ||
|
|
70f50dd8bc | ||
|
|
3e25b97b99 | ||
|
|
22927c2716 | ||
|
|
8ab4ee8e0e | ||
|
|
99aa34f27e | ||
|
|
48be8544cf | ||
|
|
ee411ac74f | ||
|
|
c233ddb520 | ||
|
|
0cfe87cb72 | ||
|
|
7998b296a2 | ||
|
|
9e20f66bf5 | ||
|
|
1dc943ef5b | ||
|
|
0f63fdac4e | ||
|
|
ec8c516aa3 | ||
|
|
58be8f91c0 | ||
|
|
2036ac3dc8 | ||
|
|
17f83f746a | ||
|
|
bcd1cbe920 | ||
|
|
3993263615 | ||
|
|
97bd4de4f1 | ||
|
|
2fc29ff7c8 | ||
|
|
4a74016b52 | ||
|
|
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 |
BIN
.github/sponsors/awesome.png
vendored
Normal file
BIN
.github/sponsors/awesome.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
|||||||
|
|
||||||
|
|
||||||
# Deploy only the dokploy app
|
# Deploy only the dokploy app
|
||||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
||||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://awesome.tools/" target="_blank">
|
||||||
|
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
<!-- Premium Supporters 🥇 -->
|
||||||
|
|||||||
209
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
209
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Registry } from "@dokploy/server";
|
||||||
|
import { getRegistryTag } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("getRegistryTag", () => {
|
||||||
|
// Helper to create a mock registry
|
||||||
|
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
||||||
|
return {
|
||||||
|
registryId: "test-registry-id",
|
||||||
|
registryName: "Test Registry",
|
||||||
|
username: "myuser",
|
||||||
|
password: "test-password",
|
||||||
|
registryUrl: "docker.io",
|
||||||
|
registryType: "cloud",
|
||||||
|
imagePrefix: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
organizationId: "test-org-id",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("with username (no imagePrefix)", () => {
|
||||||
|
it("should handle simple image name without tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username already present (no duplication)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username and tag already present", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image name with username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "siumauricio" });
|
||||||
|
const result = getRegistryTag(
|
||||||
|
registry,
|
||||||
|
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe(
|
||||||
|
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with different username (should not duplicate)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with full registry URL (no username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "docker.io/nginx");
|
||||||
|
// Should add username since imageName doesn't have one
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL and username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
||||||
|
// Should not duplicate username even if registry URL is different
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL (different username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
||||||
|
// Should use registry username, not the one in imageName
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with imagePrefix", () => {
|
||||||
|
it("should use imagePrefix instead of username", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use imagePrefix with image tag", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix with username already in image name", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix matching image name prefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
||||||
|
// Should not duplicate prefix
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without registryUrl", () => {
|
||||||
|
it("should work without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work without registryUrl with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username already present without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with custom registryUrl", () => {
|
||||||
|
it("should handle custom registry URL", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with username already present", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty image name", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "");
|
||||||
|
expect(result).toBe("docker.io/myuser/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with multiple slashes", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/suborg/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username at different position", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/myuser/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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 () => {
|
it("should verify nixpacks command is called with correct app", async () => {
|
||||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -220,7 +220,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -241,7 +241,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
|
|
||||||
it("should execute commands in correct order", async () => {
|
it("should execute commands in correct order", async () => {
|
||||||
const mockNixpacksCommand = "nixpacks build";
|
const mockNixpacksCommand = "nixpacks build";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -260,7 +260,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
|
|
||||||
it("should include log redirection in command", async () => {
|
it("should include log redirection in command", async () => {
|
||||||
const mockCommand = "nixpacks build";
|
const mockCommand = "nixpacks build";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const baseApp: ApplicationNested = {
|
|||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.2.2",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
buildServerId: "",
|
||||||
@@ -41,6 +42,9 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
@@ -64,6 +68,7 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
@@ -54,4 +54,22 @@ describe("processLogs", () => {
|
|||||||
const result = parseRawConfig(entryWithWhitespace);
|
const result = parseRawConfig(entryWithWhitespace);
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should filter out Dokploy dashboard requests", () => {
|
||||||
|
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||||
|
|
||||||
|
// Test with only Dokploy dashboard entry - should be filtered out
|
||||||
|
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
||||||
|
expect(resultOnlyDokploy.data).toHaveLength(0);
|
||||||
|
expect(resultOnlyDokploy.totalCount).toBe(0);
|
||||||
|
|
||||||
|
// Test with mixed entries - Dokploy should be filtered, others should remain
|
||||||
|
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
||||||
|
const resultMixed = parseRawConfig(mixedEntries);
|
||||||
|
expect(resultMixed.data).toHaveLength(1);
|
||||||
|
expect(resultMixed.totalCount).toBe(1);
|
||||||
|
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
||||||
|
"dokploy-service-app@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
type MockCreateServiceOptions = {
|
||||||
TaskTemplate?: {
|
TaskTemplate?: {
|
||||||
|
|||||||
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Empty string variables", () => {
|
||||||
|
it("should replace variables with empty string values correctly", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
non_empty: "value",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = processValue("${smtp_username}", variables, mockSchema);
|
||||||
|
expect(result1).toBe("");
|
||||||
|
|
||||||
|
const result2 = processValue("${smtp_password}", variables, mockSchema);
|
||||||
|
expect(result2).toBe("");
|
||||||
|
|
||||||
|
const result3 = processValue("${non_empty}", variables, mockSchema);
|
||||||
|
expect(result3).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not replace undefined variables", () => {
|
||||||
|
const variables = {
|
||||||
|
defined_var: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processValue("${undefined_var}", variables, mockSchema);
|
||||||
|
expect(result).toBe("${undefined_var}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed empty and non-empty variables in template", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_address: "smtp.example.com",
|
||||||
|
smtp_port: "2525",
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const template =
|
||||||
|
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
|
||||||
|
const result = processValue(template, variables, mockSchema);
|
||||||
|
expect(result).toBe(
|
||||||
|
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("${jwt}", () => {
|
describe("${jwt}", () => {
|
||||||
it("should generate a JWT string", () => {
|
it("should generate a JWT string", () => {
|
||||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const baseAdmin: User = {
|
|||||||
enablePaidFeatures: false,
|
enablePaidFeatures: false,
|
||||||
allowImpersonation: false,
|
allowImpersonation: false,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -61,7 +63,6 @@ const baseAdmin: User = {
|
|||||||
expirationDate: "",
|
expirationDate: "",
|
||||||
id: "",
|
id: "",
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
name: "",
|
|
||||||
createdAt2: new Date().toISOString(),
|
createdAt2: new Date().toISOString(),
|
||||||
emailVerified: false,
|
emailVerified: false,
|
||||||
image: "",
|
image: "",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const baseApp: ApplicationNested = {
|
|||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
@@ -17,6 +18,9 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
args: [],
|
args: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
endpointSpecSwarm: null,
|
endpointSpecSwarm: null,
|
||||||
@@ -46,6 +50,7 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
@@ -38,10 +38,31 @@ interface Props {
|
|||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
buildServerId: z.string().min(1, "Build server is required"),
|
.object({
|
||||||
buildRegistryId: z.string().min(1, "Build registry is required"),
|
buildServerId: z.string().optional(),
|
||||||
});
|
buildRegistryId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// Both empty/none is valid
|
||||||
|
const buildServerIsNone =
|
||||||
|
!data.buildServerId || data.buildServerId === "none";
|
||||||
|
const buildRegistryIsNone =
|
||||||
|
!data.buildRegistryId || data.buildRegistryId === "none";
|
||||||
|
|
||||||
|
// Both should be either filled or empty
|
||||||
|
if (buildServerIsNone && buildRegistryIsNone) return true;
|
||||||
|
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Both Build Server and Build Registry must be selected together, or both set to None",
|
||||||
|
path: ["buildServerId"], // Show error on buildServerId field
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>;
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
@@ -121,6 +142,11 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
container starts running.
|
container starts running.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
|
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> Build Server and Build Registry must be
|
||||||
|
configured together. You can either select both or set both to None.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
{!registries || registries.length === 0 ? (
|
{!registries || registries.length === 0 ? (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to add at least one registry to use build servers. Please
|
You need to add at least one registry to use build servers. Please
|
||||||
@@ -147,7 +173,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Build Server</FormLabel>
|
<FormLabel>Build Server</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build registry to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildRegistryId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={field.value || "none"}
|
value={field.value || "none"}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -197,7 +229,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Build Registry</FormLabel>
|
<FormLabel>Build Registry</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build server to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildServerId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={field.value || "none"}
|
value={field.value || "none"}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const ShowDeployments = ({
|
|||||||
See the last 10 deployments for this {type}
|
See the last 10 deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</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") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -373,7 +373,19 @@ export const ShowDeployments = ({
|
|||||||
type === "application" && (
|
type === "application" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rollback to this deployment"
|
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"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await rollback({
|
await rollback({
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -502,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
to make your traefik.me domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Form } from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
buildSecrets: z.string(),
|
||||||
|
createEnvFile: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
buildSecrets: "",
|
||||||
|
createEnvFile: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
const currentBuildSecrets = form.watch("buildSecrets");
|
const currentBuildSecrets = form.watch("buildSecrets");
|
||||||
|
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||||
|
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
buildSecrets: data.buildSecrets || "",
|
||||||
|
createEnvFile: data.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
buildSecrets: formData.buildSecrets,
|
||||||
|
createEnvFile: formData.createEnvFile,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
buildSecrets: data?.buildSecrets || "",
|
buildSecrets: data?.buildSecrets || "",
|
||||||
|
createEnvFile: data?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +182,31 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{data?.buildType === "dockerfile" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="createEnvFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When enabled, an .env file will be created in the same
|
||||||
|
directory as your Dockerfile during the build process.
|
||||||
|
Disable this if you don't want to generate an environment
|
||||||
|
file.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
<div className="flex flex-row justify-end gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
|
|||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
|
|||||||
name="host"
|
name="host"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
|
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||||
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
|
not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,13 +21,37 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
rollbackActive: z.boolean(),
|
.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>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
const { mutateAsync: updateApplication, isLoading } =
|
const { mutateAsync: updateApplication, isLoading } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
rollbackActive: application?.rollbackActive ?? false,
|
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) => {
|
const onSubmit = async (data: FormValues) => {
|
||||||
await updateApplication({
|
await updateApplication({
|
||||||
applicationId,
|
applicationId,
|
||||||
rollbackActive: data.rollbackActive,
|
rollbackActive: data.rollbackActive,
|
||||||
|
rollbackRegistryId:
|
||||||
|
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||||
|
? null
|
||||||
|
: data.rollbackRegistryId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Rollback settings updated");
|
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}>
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -60,6 +60,30 @@ export const commonCronExpressions = [
|
|||||||
{ label: "Custom", value: "custom" },
|
{ 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
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
@@ -75,6 +99,7 @@ const formSchema = z
|
|||||||
"dokploy-server",
|
"dokploy-server",
|
||||||
]),
|
]),
|
||||||
script: z.string(),
|
script: z.string(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||||
@@ -213,6 +238,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: "",
|
serviceName: "",
|
||||||
scheduleType: scheduleType || "application",
|
scheduleType: scheduleType || "application",
|
||||||
script: "",
|
script: "",
|
||||||
|
timezone: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,6 +277,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: schedule.serviceName || "",
|
serviceName: schedule.serviceName || "",
|
||||||
scheduleType: schedule.scheduleType,
|
scheduleType: schedule.scheduleType,
|
||||||
script: schedule.script || "",
|
script: schedule.script || "",
|
||||||
|
timezone: schedule.timezone || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, schedule, scheduleId]);
|
}, [form, schedule, scheduleId]);
|
||||||
@@ -464,6 +491,54 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
formControl={form.control}
|
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 === "application" ||
|
||||||
scheduleTypeForm === "compose") && (
|
scheduleTypeForm === "compose") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||||
// message: "The server is running on port 8080" }
|
// message: "The server is running on port 8080" }
|
||||||
const logRegex =
|
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
|
return logString
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
const match = line.match(logRegex);
|
const match = line.match(logRegex);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const [, , timestamp, message] = match;
|
const { timestamp, message } = match.groups ?? {};
|
||||||
|
|
||||||
if (!message?.trim()) return null;
|
if (!message?.trim()) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast.success("Successfully impersonating user", {
|
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();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate flex flex-col items-start">
|
<span className="truncate flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selectedUser.name || ""}
|
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectedUser.email}
|
{selectedUser.email}
|
||||||
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="flex flex-col items-start">
|
<span className="flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{user.name || ""}
|
{`${user.name} ${user.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{user.email} • {user.role}
|
{user.email} • {user.role}
|
||||||
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
src={data?.user?.image || ""}
|
src={data?.user?.image || ""}
|
||||||
alt={data?.user?.name || ""}
|
alt={
|
||||||
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||||
|
""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||||
|
"U"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => {
|
|||||||
Impersonating
|
Impersonating
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{data?.user?.name || ""}
|
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
<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 = {
|
const defaultData = {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: 0,
|
value: "0%",
|
||||||
time: "",
|
time: "",
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
@@ -46,7 +46,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export interface DockerStats {
|
export interface DockerStats {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: number;
|
value: string;
|
||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
memory: {
|
memory: {
|
||||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Used: {currentData.cpu.value}
|
Used: {currentData.cpu.value}
|
||||||
</span>
|
</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} />
|
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||||
import {
|
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||||
ChevronDownIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Terminal,
|
|
||||||
TrashIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -109,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to create environment");
|
toast.error(
|
||||||
|
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update environment");
|
toast.error(
|
||||||
|
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
|
|
||||||
// Redirect to production if we deleted the current environment
|
// Redirect to first available environment if we deleted the current environment
|
||||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||||
const productionEnv = environments?.find(
|
const firstEnv = environments?.find(
|
||||||
(env) => env.name === "production",
|
(env) => env.environmentId !== selectedEnvironment.environmentId,
|
||||||
);
|
);
|
||||||
if (productionEnv) {
|
if (firstEnv) {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// No other environments, redirect to project page
|
||||||
|
router.push(`/dashboard/project/${projectId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -246,22 +246,8 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<div className="flex items-center gap-1 px-2">
|
||||||
{/* Action buttons for non-production environments */}
|
{!environment.isDefault && (
|
||||||
{/* <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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -273,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{canDeleteEnvironments && (
|
{canDeleteEnvironments && !environment.isDefault && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openDeleteDialog(environment);
|
openDeleteDialog(environment);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-3 w-3" />
|
<TrashIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
@@ -54,16 +55,23 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { TimeBadge } from "@/components/ui/time-badge";
|
import { TimeBadge } from "@/components/ui/time-badge";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
import { ProjectEnvironment } from "./project-environment";
|
import { ProjectEnvironment } from "./project-environment";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading } = api.project.all.useQuery();
|
const { data, isLoading } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
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>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||||
@@ -75,14 +83,41 @@ export const ShowProjects = () => {
|
|||||||
localStorage.setItem("projectsSort", sortBy);
|
localStorage.setItem("projectsSort", sortBy);
|
||||||
}, [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(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
// First filter by search query
|
|
||||||
const filtered = data.filter(
|
const filtered = data.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
project.name
|
||||||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
.toLowerCase()
|
||||||
|
.includes(debouncedSearchQuery.toLowerCase()) ||
|
||||||
|
project.description
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(debouncedSearchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then sort the filtered results
|
// Then sort the filtered results
|
||||||
@@ -130,7 +165,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
return direction === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [data, searchQuery, sortBy]);
|
}, [data, debouncedSearchQuery, sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -251,13 +286,17 @@ export const ShowProjects = () => {
|
|||||||
)
|
)
|
||||||
.some(Boolean);
|
.some(Boolean);
|
||||||
|
|
||||||
|
const productionEnvironment = project?.environments.find(
|
||||||
|
(env) => env.isDefault,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
className="w-full lg:max-w-md"
|
className="w-full lg:max-w-md"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||||
{haveServicesWithDomains ? (
|
{haveServicesWithDomains ? (
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ export const ShowRequests = () => {
|
|||||||
to: Date | undefined;
|
to: Date | undefined;
|
||||||
}>(getDefaultDateRange());
|
}>(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(() => {
|
useEffect(() => {
|
||||||
if (logCleanupStatus) {
|
if (logCleanupStatus) {
|
||||||
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
||||||
@@ -85,16 +104,18 @@ export const ShowRequests = () => {
|
|||||||
See all the incoming requests that pass trough Traefik
|
See all the incoming requests that pass trough Traefik
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
{shouldShowWarning && (
|
||||||
When you activate, you need to reload traefik to apply the
|
<AlertBlock type="warning">
|
||||||
changes, you can reload traefik in{" "}
|
When you activate, you need to reload traefik to apply the
|
||||||
<Link
|
changes, you can reload traefik in{" "}
|
||||||
href="/dashboard/settings/server"
|
<Link
|
||||||
className="text-primary"
|
href="/dashboard/settings/server"
|
||||||
>
|
className="text-primary"
|
||||||
Settings
|
>
|
||||||
</Link>
|
Settings
|
||||||
</AlertBlock>
|
</Link>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
<div className="flex w-full gap-4 justify-end items-center">
|
<div className="flex w-full gap-4 justify-end items-center">
|
||||||
|
|||||||
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
const productionEnvironment = project.environments.find(
|
// Find default environment, or fall back to first environment
|
||||||
(environment) => environment.name === "production",
|
const defaultEnvironment =
|
||||||
);
|
project.environments.find(
|
||||||
|
(environment) => environment.isDefault,
|
||||||
|
) || project?.environments?.[0];
|
||||||
|
|
||||||
if (!productionEnvironment) return null;
|
if (!defaultEnvironment) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
{project.name} / {productionEnvironment!.name}
|
{project.name} / {defaultEnvironment.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
|
|||||||
username: z.string().min(1, {
|
username: z.string().min(1, {
|
||||||
message: "Username is required",
|
message: "Username is required",
|
||||||
}),
|
}),
|
||||||
password: z.string().min(1, {
|
password: z.string(),
|
||||||
message: "Password is required",
|
registryUrl: z
|
||||||
}),
|
.string()
|
||||||
registryUrl: z.string(),
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// If empty or undefined, skip validation (field is optional)
|
||||||
|
if (!val || val.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Validate that it's a valid hostname (no protocol, no path, optional port)
|
||||||
|
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
|
||||||
|
// Invalid: https://example.com, example.com/path
|
||||||
|
const trimmed = val.trim();
|
||||||
|
// Check for protocol or path - these are not allowed
|
||||||
|
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
|
||||||
|
// Allow optional port at the end
|
||||||
|
const hostnameRegex =
|
||||||
|
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
|
||||||
|
return hostnameRegex.test(trimmed);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
|
||||||
|
},
|
||||||
|
),
|
||||||
imagePrefix: z.string(),
|
imagePrefix: z.string(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
|
isEditing: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
||||||
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const { mutateAsync, error, isError } = registryId
|
const { mutateAsync, error, isError } = registryId
|
||||||
? api.registry.update.useMutation()
|
? api.registry.update.useMutation()
|
||||||
: api.registry.create.useMutation();
|
: api.registry.create.useMutation();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: deployServers } = api.server.withSSHKey.useQuery();
|
||||||
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
|
const servers = [...(deployServers || []), ...(buildServers || [])];
|
||||||
const {
|
const {
|
||||||
mutateAsync: testRegistry,
|
mutateAsync: testRegistry,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: testRegistryError,
|
error: testRegistryError,
|
||||||
isError: testRegistryIsError,
|
isError: testRegistryIsError,
|
||||||
} = api.registry.testRegistry.useMutation();
|
} = api.registry.testRegistry.useMutation();
|
||||||
|
const {
|
||||||
|
mutateAsync: testRegistryById,
|
||||||
|
isLoading: isLoadingById,
|
||||||
|
error: testRegistryByIdError,
|
||||||
|
isError: testRegistryByIdIsError,
|
||||||
|
} = api.registry.testRegistryById.useMutation();
|
||||||
const form = useForm<AddRegistry>({
|
const form = useForm<AddRegistry>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
registryName: "",
|
registryName: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
isEditing: !!registryId,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRegistrySchema),
|
resolver: zodResolver(
|
||||||
|
AddRegistrySchema.refine(
|
||||||
|
(data) => {
|
||||||
|
// When creating a new registry, password is required
|
||||||
|
if (
|
||||||
|
!data.isEditing &&
|
||||||
|
(!data.password || data.password.length === 0)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Password is required",
|
||||||
|
path: ["password"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const password = form.watch("password");
|
const password = form.watch("password");
|
||||||
@@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const registryName = form.watch("registryName");
|
const registryName = form.watch("registryName");
|
||||||
const imagePrefix = form.watch("imagePrefix");
|
const imagePrefix = form.watch("imagePrefix");
|
||||||
const serverId = form.watch("serverId");
|
const serverId = form.watch("serverId");
|
||||||
|
const selectedServer = servers?.find(
|
||||||
|
(server) => server.serverId === serverId,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registry) {
|
if (registry) {
|
||||||
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: registry.registryUrl,
|
registryUrl: registry.registryUrl,
|
||||||
imagePrefix: registry.imagePrefix || "",
|
imagePrefix: registry.imagePrefix || "",
|
||||||
registryName: registry.registryName,
|
registryName: registry.registryName,
|
||||||
|
isEditing: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
isEditing: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddRegistry) => {
|
const onSubmit = async (data: AddRegistry) => {
|
||||||
await mutateAsync({
|
const payload: any = {
|
||||||
password: data.password,
|
|
||||||
registryName: data.registryName,
|
registryName: data.registryName,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
registryUrl: data.registryUrl,
|
registryUrl: data.registryUrl || "",
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: data.imagePrefix,
|
imagePrefix: data.imagePrefix,
|
||||||
serverId: data.serverId,
|
serverId: data.serverId,
|
||||||
registryId: registryId || "",
|
registryId: registryId || "",
|
||||||
})
|
};
|
||||||
|
|
||||||
|
// Only include password if it's been provided (not empty)
|
||||||
|
// When editing, empty password means "keep the existing password"
|
||||||
|
if (data.password && data.password.length > 0) {
|
||||||
|
payload.password = data.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(payload)
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
await utils.registry.all.invalidate();
|
await utils.registry.all.invalidate();
|
||||||
toast.success(registryId ? "Registry updated" : "Registry added");
|
toast.success(registryId ? "Registry updated" : "Registry added");
|
||||||
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
Fill the next fields to add a external registry.
|
Fill the next fields to add a external registry.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(isError || testRegistryIsError) && (
|
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
|
||||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
{testRegistryError?.message || error?.message || ""}
|
{testRegistryError?.message ||
|
||||||
|
testRegistryByIdError?.message ||
|
||||||
|
error?.message ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
|
||||||
|
{registryId && (
|
||||||
|
<FormDescription>
|
||||||
|
Leave blank to keep existing password. Enter new
|
||||||
|
password to test or update it.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password"
|
placeholder={
|
||||||
|
registryId
|
||||||
|
? "Leave blank to keep existing"
|
||||||
|
: "Password"
|
||||||
|
}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -261,6 +338,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Registry URL</FormLabel>
|
<FormLabel>Registry URL</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enter only the hostname (e.g.,
|
||||||
|
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
||||||
@@ -282,8 +363,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select a server to test the registry. this will run the
|
{!isCloud ? (
|
||||||
following command on the server
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Choose where to authenticate with the registry. By
|
||||||
|
default, authentication occurs on the Dokploy
|
||||||
|
server. Select a specific server to authenticate
|
||||||
|
from that server instead.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Select a server to authenticate with the registry.
|
||||||
|
The authentication will be performed from the
|
||||||
|
selected server.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
@@ -294,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<SelectValue placeholder="Select a server" />
|
<SelectValue placeholder="Select a server" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{deployServers && deployServers.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Deploy Servers</SelectLabel>
|
||||||
|
{deployServers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
|
{buildServers && buildServers.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Build Servers</SelectLabel>
|
||||||
|
{buildServers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Servers</SelectLabel>
|
|
||||||
{servers?.map((server) => (
|
|
||||||
<SelectItem
|
|
||||||
key={server.serverId}
|
|
||||||
value={server.serverId}
|
|
||||||
>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value={"none"}>None</SelectItem>
|
<SelectItem value={"none"}>None</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading || isLoadingById}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
// When editing with empty password, use the existing password from DB
|
||||||
|
if (registryId && (!password || password.length === 0)) {
|
||||||
|
await testRegistryById({
|
||||||
|
registryId: registryId || "",
|
||||||
|
...(serverId && { serverId }),
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
toast.success("Registry Tested Successfully");
|
||||||
|
} else {
|
||||||
|
toast.error("Registry Test Failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error testing the registry");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When creating, password is required
|
||||||
|
if (!registryId && (!password || password.length === 0)) {
|
||||||
|
form.setError("password", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Password is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When creating or editing with new password, validate and test with provided credentials
|
||||||
const validationResult = AddRegistrySchema.safeParse({
|
const validationResult = AddRegistrySchema.safeParse({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryName: "Dokploy Registry",
|
registryName: "Dokploy Registry",
|
||||||
imagePrefix,
|
imagePrefix,
|
||||||
serverId,
|
serverId,
|
||||||
|
isEditing: !!registryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
await testRegistry({
|
await testRegistry({
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
registryUrl: registryUrl,
|
registryUrl: registryUrl || "",
|
||||||
registryName: registryName,
|
registryName: registryName,
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: imagePrefix,
|
imagePrefix: imagePrefix,
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||||
await utils.destination.all.invalidate();
|
await utils.destination.all.invalidate();
|
||||||
|
if (destinationId) {
|
||||||
|
await utils.destination.one.invalidate({ destinationId });
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -44,6 +50,7 @@ const notificationBaseSchema = z.object({
|
|||||||
appDeploy: z.boolean().default(false),
|
appDeploy: z.boolean().default(false),
|
||||||
appBuildError: z.boolean().default(false),
|
appBuildError: z.boolean().default(false),
|
||||||
databaseBackup: z.boolean().default(false),
|
databaseBackup: z.boolean().default(false),
|
||||||
|
volumeBackup: z.boolean().default(false),
|
||||||
dokployRestart: z.boolean().default(false),
|
dokployRestart: z.boolean().default(false),
|
||||||
dockerCleanup: z.boolean().default(false),
|
dockerCleanup: z.boolean().default(false),
|
||||||
serverThreshold: 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),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.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
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("lark"),
|
type: z.literal("lark"),
|
||||||
@@ -144,6 +166,10 @@ export const notificationsMap = {
|
|||||||
icon: <NtfyIcon />,
|
icon: <NtfyIcon />,
|
||||||
label: "ntfy",
|
label: "ntfy",
|
||||||
},
|
},
|
||||||
|
custom: {
|
||||||
|
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||||
|
label: "Custom",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||||
@@ -179,6 +205,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
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
|
const slackMutation = notificationId
|
||||||
? api.notification.updateSlack.useMutation()
|
? api.notification.updateSlack.useMutation()
|
||||||
: api.notification.createSlack.useMutation();
|
: api.notification.createSlack.useMutation();
|
||||||
@@ -217,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
name: "toAddresses" as never,
|
name: "toAddresses" as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: headerFields,
|
||||||
|
append: appendHeader,
|
||||||
|
remove: removeHeader,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "headers" as never,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "email" && fields.length === 0) {
|
if (type === "email" && fields.length === 0) {
|
||||||
append("");
|
append("");
|
||||||
@@ -231,6 +273,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
webhookUrl: notification.slack?.webhookUrl,
|
webhookUrl: notification.slack?.webhookUrl,
|
||||||
channel: notification.slack?.channel || "",
|
channel: notification.slack?.channel || "",
|
||||||
@@ -244,6 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
botToken: notification.telegram?.botToken,
|
botToken: notification.telegram?.botToken,
|
||||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||||
chatId: notification.telegram?.chatId,
|
chatId: notification.telegram?.chatId,
|
||||||
@@ -258,6 +302,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
decoration: notification.discord?.decoration || undefined,
|
decoration: notification.discord?.decoration || undefined,
|
||||||
@@ -271,6 +316,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
smtpServer: notification.email?.smtpServer,
|
smtpServer: notification.email?.smtpServer,
|
||||||
smtpPort: notification.email?.smtpPort,
|
smtpPort: notification.email?.smtpPort,
|
||||||
@@ -288,6 +334,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
decoration: notification.gotify?.decoration || undefined,
|
decoration: notification.gotify?.decoration || undefined,
|
||||||
@@ -302,6 +349,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
accessToken: notification.ntfy?.accessToken || "",
|
accessToken: notification.ntfy?.accessToken || "",
|
||||||
topic: notification.ntfy?.topic,
|
topic: notification.ntfy?.topic,
|
||||||
@@ -321,6 +369,28 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
webhookUrl: notification.lark?.webhookUrl,
|
webhookUrl: notification.lark?.webhookUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
|
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,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -337,6 +407,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
|
custom: customMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: NotificationSchema) => {
|
const onSubmit = async (data: NotificationSchema) => {
|
||||||
@@ -345,6 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy,
|
appDeploy,
|
||||||
dokployRestart,
|
dokployRestart,
|
||||||
databaseBackup,
|
databaseBackup,
|
||||||
|
volumeBackup,
|
||||||
dockerCleanup,
|
dockerCleanup,
|
||||||
serverThreshold,
|
serverThreshold,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -355,6 +427,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -369,6 +442,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
botToken: data.botToken,
|
botToken: data.botToken,
|
||||||
messageThreadId: data.messageThreadId || "",
|
messageThreadId: data.messageThreadId || "",
|
||||||
chatId: data.chatId,
|
chatId: data.chatId,
|
||||||
@@ -384,6 +458,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
decoration: data.decoration,
|
decoration: data.decoration,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -398,6 +473,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
smtpServer: data.smtpServer,
|
smtpServer: data.smtpServer,
|
||||||
smtpPort: data.smtpPort,
|
smtpPort: data.smtpPort,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -416,6 +492,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
appToken: data.appToken,
|
appToken: data.appToken,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
@@ -431,6 +508,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
accessToken: data.accessToken || "",
|
accessToken: data.accessToken || "",
|
||||||
topic: data.topic,
|
topic: data.topic,
|
||||||
@@ -446,6 +524,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerCleanup: dockerCleanup,
|
dockerCleanup: dockerCleanup,
|
||||||
@@ -453,6 +532,33 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
larkId: notification?.larkId || "",
|
larkId: notification?.larkId || "",
|
||||||
serverThreshold: serverThreshold,
|
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,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
headers: headersRecord,
|
||||||
|
name: data.name,
|
||||||
|
dockerCleanup: dockerCleanup,
|
||||||
|
serverThreshold: serverThreshold,
|
||||||
|
notificationId: notificationId || "",
|
||||||
|
customId: notification?.customId || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -1043,7 +1149,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" && (
|
{type === "lark" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1134,6 +1325,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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dockerCleanup"
|
name="dockerCleanup"
|
||||||
@@ -1215,7 +1427,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingEmail ||
|
isLoadingEmail ||
|
||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark
|
isLoadingLark ||
|
||||||
|
isLoadingCustom
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1269,6 +1482,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
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");
|
toast.success("Connection Success");
|
||||||
} catch (error) {
|
} 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 { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
|
|||||||
<NtfyIcon className="size-6" />
|
<NtfyIcon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notification.notificationType === "custom" && (
|
||||||
|
<div className="flex items-center justify-center rounded-lg ">
|
||||||
|
<PenBoxIcon className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{notification.notificationType === "lark" && (
|
{notification.notificationType === "lark" && (
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
<div className="flex items-center justify-center rounded-lg">
|
||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
<LarkIcon className="size-7 text-muted-foreground" />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const profileSchema = z.object({
|
|||||||
currentPassword: z.string().nullable(),
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
allowImpersonation: z.boolean().optional().default(false),
|
allowImpersonation: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +89,8 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||||
name: data?.user?.name || "",
|
name: data?.user?.firstName || "",
|
||||||
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
});
|
});
|
||||||
@@ -102,7 +104,8 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: form.getValues("currentPassword") || "",
|
currentPassword: form.getValues("currentPassword") || "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation,
|
allowImpersonation: data?.user?.allowImpersonation,
|
||||||
name: data?.user?.name || "",
|
name: data?.user?.firstName || "",
|
||||||
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepValues: true,
|
keepValues: true,
|
||||||
@@ -127,6 +130,7 @@ export const ProfileForm = () => {
|
|||||||
currentPassword: values.currentPassword || undefined,
|
currentPassword: values.currentPassword || undefined,
|
||||||
allowImpersonation: values.allowImpersonation,
|
allowImpersonation: values.allowImpersonation,
|
||||||
name: values.name || undefined,
|
name: values.name || undefined,
|
||||||
|
lastName: values.lastName || undefined,
|
||||||
});
|
});
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Profile Updated");
|
toast.success("Profile Updated");
|
||||||
@@ -136,6 +140,7 @@ export const ProfileForm = () => {
|
|||||||
image: values.image,
|
image: values.image,
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
name: values.name || "",
|
name: values.name || "",
|
||||||
|
lastName: values.lastName || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Error updating the profile");
|
toast.error("Error updating the profile");
|
||||||
@@ -180,9 +185,22 @@ export const ProfileForm = () => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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">
|
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(
|
{getFallbackAvatarInitials(
|
||||||
data?.user?.name,
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Activity } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import { ShowStorageActions } from "./show-storage-actions";
|
import { ShowStorageActions } from "./show-storage-actions";
|
||||||
import { ShowTraefikActions } from "./show-traefik-actions";
|
import { ShowTraefikActions } from "./show-traefik-actions";
|
||||||
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowServerActions = ({ serverId }: Props) => {
|
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View Actions
|
View Actions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Cleaned all");
|
toast.success("Cleaning in progress... Please wait");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error cleaning all");
|
toast.error("Error cleaning all");
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</EditTraefikEnv>
|
</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 () => {
|
onClick={async () => {
|
||||||
await toggleDashboard({
|
await toggleDashboard({
|
||||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
);
|
);
|
||||||
refetchDashboard();
|
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>
|
<DropdownMenuItem
|
||||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
onSelect={(e) => e.preventDefault()}
|
||||||
</span>
|
className="w-full cursor-pointer space-x-3"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<span>
|
||||||
|
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||||
|
Dashboard
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
<ManageTraefikPorts serverId={serverId}>
|
<ManageTraefikPorts serverId={serverId}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon, Pencil } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -59,9 +59,10 @@ type Schema = z.infer<typeof Schema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId }: Props) => {
|
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -137,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{serverId ? (
|
||||||
{serverId ? (
|
asButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
)
|
||||||
|
) : (
|
||||||
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer space-x-3">
|
<Button className="cursor-pointer space-x-3">
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Create Server
|
Create Server
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-3xl ">
|
<DialogContent className="sm:max-w-3xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
|
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -36,9 +36,10 @@ import { ValidateServer } from "./validate-server";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupServer = ({ serverId }: Props) => {
|
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: server } = api.server.one.useQuery(
|
const { data: server } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -81,14 +82,23 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Setup Server
|
Setup Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
|
import {
|
||||||
|
KeyIcon,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
ServerIcon,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Key,
|
||||||
|
Network,
|
||||||
|
Terminal,
|
||||||
|
Settings,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
@@ -24,14 +37,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Table,
|
Tooltip,
|
||||||
TableBody,
|
TooltipContent,
|
||||||
TableCaption,
|
TooltipProvider,
|
||||||
TableCell,
|
TooltipTrigger,
|
||||||
TableHead,
|
} from "@/components/ui/tooltip";
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||||
import { TerminalModal } from "../web-server/terminal-modal";
|
import { TerminalModal } from "../web-server/terminal-modal";
|
||||||
@@ -59,7 +69,7 @@ export const ShowServers = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{query?.success && isCloud && <WelcomeSuscription />}
|
{query?.success && isCloud && <WelcomeSuscription />}
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
@@ -114,240 +124,309 @@ export const ShowServers = () => {
|
|||||||
<HandleServers />
|
<HandleServers />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<Table>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<TableCaption>
|
{data?.map((server) => {
|
||||||
<div className="flex flex-col gap-4">
|
const canDelete = server.totalSum === 0;
|
||||||
See all servers
|
const isActive = server.serverStatus === "active";
|
||||||
</div>
|
const isBuildServer = server.serverType === "build";
|
||||||
</TableCaption>
|
return (
|
||||||
<TableHeader>
|
<Card
|
||||||
<TableRow>
|
key={server.serverId}
|
||||||
<TableHead className="text-left">Name</TableHead>
|
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||||
{isCloud && (
|
>
|
||||||
<TableHead className="text-center">
|
<CardHeader className="pb-3">
|
||||||
Status
|
<div className="flex items-start justify-between">
|
||||||
</TableHead>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<ServerIcon className="size-5 text-muted-foreground" />
|
||||||
<TableHead className="text-center">
|
<CardTitle className="text-lg">
|
||||||
Type
|
{server.name}
|
||||||
</TableHead>
|
</CardTitle>
|
||||||
<TableHead className="text-center">
|
</div>
|
||||||
IP Address
|
{isActive &&
|
||||||
</TableHead>
|
server.sshKeyId &&
|
||||||
<TableHead className="text-center">
|
!isBuildServer && (
|
||||||
Port
|
<DropdownMenu>
|
||||||
</TableHead>
|
<DropdownMenuTrigger asChild>
|
||||||
<TableHead className="text-center">
|
<Button
|
||||||
Username
|
variant="ghost"
|
||||||
</TableHead>
|
className="h-8 w-8 p-0"
|
||||||
<TableHead className="text-center">
|
>
|
||||||
SSH Key
|
<span className="sr-only">
|
||||||
</TableHead>
|
More options
|
||||||
<TableHead className="text-center">
|
</span>
|
||||||
Created
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</TableHead>
|
</Button>
|
||||||
<TableHead className="text-right">
|
</DropdownMenuTrigger>
|
||||||
Actions
|
<DropdownMenuContent align="end">
|
||||||
</TableHead>
|
<DropdownMenuLabel>
|
||||||
</TableRow>
|
Advanced
|
||||||
</TableHeader>
|
</DropdownMenuLabel>
|
||||||
<TableBody>
|
<ShowTraefikFileSystemModal
|
||||||
{data?.map((server) => {
|
serverId={server.serverId}
|
||||||
const canDelete = server.totalSum === 0;
|
/>
|
||||||
const isActive = server.serverStatus === "active";
|
<ShowDockerContainersModal
|
||||||
const isBuildServer =
|
serverId={server.serverId}
|
||||||
server.serverType === "build";
|
/>
|
||||||
return (
|
{isCloud && (
|
||||||
<TableRow key={server.serverId}>
|
<ShowMonitoringModal
|
||||||
<TableCell className="text-left">
|
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||||
{server.name}
|
token={
|
||||||
</TableCell>
|
server?.metricsConfig?.server
|
||||||
{isCloud && (
|
?.token
|
||||||
<TableHead className="text-center">
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ShowSwarmOverviewModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
<ShowNodesModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
<ShowSchedulesModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex gap-2 mt-2 flex-wrap">
|
||||||
|
{isCloud && (
|
||||||
|
<>
|
||||||
|
{server.serverStatus === "active" ? (
|
||||||
|
<Badge variant="default">
|
||||||
|
{server.serverStatus}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-block">
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="cursor-help"
|
||||||
|
>
|
||||||
|
{server.serverStatus}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-xs"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
This server is deactivated due
|
||||||
|
to lack of payment. Please pay
|
||||||
|
your invoice to reactivate it.
|
||||||
|
If you think this is an error,
|
||||||
|
please contact support.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
server.serverStatus === "active"
|
isBuildServer
|
||||||
? "default"
|
? "secondary"
|
||||||
: "destructive"
|
: "default"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{server.serverStatus}
|
{server.serverType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableHead>
|
</div>
|
||||||
)}
|
</TooltipProvider>
|
||||||
<TableCell className="text-center">
|
</CardHeader>
|
||||||
<Badge
|
<CardContent className="space-y-3 flex-1 flex flex-col">
|
||||||
variant={
|
<div className="flex items-center gap-2 text-sm">
|
||||||
isBuildServer ? "secondary" : "default"
|
<Network className="size-4 text-muted-foreground" />
|
||||||
}
|
<span className="text-muted-foreground">
|
||||||
>
|
IP:
|
||||||
{server.serverType}
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{server.ipAddress}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
<span className="text-muted-foreground">
|
||||||
<TableCell className="text-center">
|
Port:
|
||||||
<Badge>{server.ipAddress}</Badge>
|
</span>
|
||||||
</TableCell>
|
<span className="font-medium">
|
||||||
<TableCell className="text-center">
|
{server.port}
|
||||||
{server.port}
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-center">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{server.username}
|
<User className="size-4 text-muted-foreground" />
|
||||||
</TableCell>
|
<span className="text-muted-foreground">
|
||||||
<TableCell className="text-right">
|
User:
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{server.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Key className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
SSH Key:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
{server.sshKeyId ? "Yes" : "No"}
|
{server.sshKeyId ? "Yes" : "No"}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right">
|
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
||||||
<span className="text-sm text-muted-foreground">
|
<Clock className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Created{" "}
|
||||||
{format(
|
{format(
|
||||||
new Date(server.createdAt),
|
new Date(server.createdAt),
|
||||||
"PPpp",
|
"PPp",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
{/* Compact Actions */}
|
||||||
<DropdownMenu>
|
{isActive && (
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto">
|
||||||
<Button
|
<TooltipProvider>
|
||||||
variant="ghost"
|
{server.sshKeyId && (
|
||||||
className="h-8 w-8 p-0"
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<span className="sr-only">
|
<div>
|
||||||
Open menu
|
<TerminalModal
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Actions
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{isActive && (
|
|
||||||
<>
|
|
||||||
{server.sshKeyId && (
|
|
||||||
<TerminalModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{t(
|
|
||||||
"settings.common.enterTerminal",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</TerminalModal>
|
|
||||||
)}
|
|
||||||
<SetupServer
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HandleServers
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{server.sshKeyId &&
|
|
||||||
!isBuildServer && (
|
|
||||||
<ShowServerActions
|
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
asButton={true}
|
||||||
)}
|
>
|
||||||
</>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TerminalModal>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Terminal</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogAction
|
<Tooltip>
|
||||||
disabled={!canDelete}
|
<TooltipTrigger asChild>
|
||||||
title={
|
<div>
|
||||||
canDelete
|
<SetupServer
|
||||||
? "Delete Server"
|
|
||||||
: "Server has active services"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
canDelete ? (
|
|
||||||
"This will delete the server and all associated data"
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
You can not delete this server
|
|
||||||
because it has active services.
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You have active services
|
|
||||||
associated with this server,
|
|
||||||
please delete them first.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
serverId: server.serverId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
`Server ${server.name} deleted successfully`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
Delete Server
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogAction>
|
|
||||||
|
|
||||||
{isActive &&
|
|
||||||
server.sshKeyId &&
|
|
||||||
!isBuildServer && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Extra
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<ShowTraefikFileSystemModal
|
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
|
asButton={true}
|
||||||
/>
|
/>
|
||||||
<ShowDockerContainersModal
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Setup Server</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<HandleServers
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
|
asButton={true}
|
||||||
/>
|
/>
|
||||||
{isCloud && (
|
</div>
|
||||||
<ShowMonitoringModal
|
</TooltipTrigger>
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
<TooltipContent>
|
||||||
token={
|
<p>Edit Server</p>
|
||||||
server?.metricsConfig
|
</TooltipContent>
|
||||||
?.server?.token
|
</Tooltip>
|
||||||
}
|
|
||||||
|
{server.sshKeyId && !isBuildServer && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ShowServerActions
|
||||||
|
serverId={server.serverId}
|
||||||
|
asButton={true}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Web Server Actions</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<ShowSwarmOverviewModal
|
<div className="flex-1" />
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
<ShowNodesModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ShowSchedulesModal
|
<Tooltip>
|
||||||
serverId={server.serverId}
|
<TooltipTrigger asChild>
|
||||||
/>
|
<div>
|
||||||
</>
|
<DialogAction
|
||||||
)}
|
disabled={!canDelete}
|
||||||
</DropdownMenuContent>
|
title={
|
||||||
</DropdownMenu>
|
canDelete
|
||||||
</TableCell>
|
? "Delete Server"
|
||||||
</TableRow>
|
: "Server has active services"
|
||||||
);
|
}
|
||||||
})}
|
description={
|
||||||
</TableBody>
|
canDelete ? (
|
||||||
</Table>
|
"This will delete the server and all associated data"
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
You can not delete this
|
||||||
|
server because it has
|
||||||
|
active services.
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You have active services
|
||||||
|
associated with this
|
||||||
|
server, please delete
|
||||||
|
them first.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
serverId: server.serverId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
`Server ${server.name} deleted successfully`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{canDelete
|
||||||
|
? "Delete Server"
|
||||||
|
: "Cannot delete - has active services"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<HandleServers />
|
<HandleServers />
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const AddInvitation = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="member">Member</SelectItem>
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<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 { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddUserPermissions } from "./add-permissions";
|
import { AddUserPermissions } from "./add-permissions";
|
||||||
|
import { ChangeRole } from "./change-role";
|
||||||
|
|
||||||
export const ShowUsers = () => {
|
export const ShowUsers = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||||
const { mutateAsync } = api.user.remove.useMutation();
|
const { mutateAsync } = api.user.remove.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -81,6 +84,52 @@ export const ShowUsers = () => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((member) => {
|
{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 (
|
return (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="w-[100px]">
|
<TableCell className="w-[100px]">
|
||||||
@@ -109,7 +158,7 @@ export const ShowUsers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
<TableCell className="text-right flex justify-end">
|
||||||
{member.role !== "owner" && (
|
{hasAnyAction ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -127,11 +176,23 @@ export const ShowUsers = () => {
|
|||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<AddUserPermissions
|
{canChangeRole && (
|
||||||
userId={member.user.id}
|
<ChangeRole
|
||||||
/>
|
memberId={member.id}
|
||||||
|
currentRole={
|
||||||
|
member.role as "admin" | "member"
|
||||||
|
}
|
||||||
|
userEmail={member.user.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isCloud && (
|
{canEditPermissions && (
|
||||||
|
<AddUserPermissions
|
||||||
|
userId={member.user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete User"
|
title="Delete User"
|
||||||
description="Are you sure you want to delete this user?"
|
description="Are you sure you want to delete this user?"
|
||||||
@@ -146,9 +207,10 @@ export const ShowUsers = () => {
|
|||||||
);
|
);
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Error deleting destination",
|
err?.message ||
|
||||||
|
"Error deleting user",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -162,66 +224,79 @@ export const ShowUsers = () => {
|
|||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogAction
|
{canUnlink && (
|
||||||
title="Unlink User"
|
<DialogAction
|
||||||
description="Are you sure you want to unlink this user?"
|
title="Unlink User"
|
||||||
type="destructive"
|
description="Are you sure you want to unlink this user?"
|
||||||
onClick={async () => {
|
type="destructive"
|
||||||
if (!isCloud) {
|
onClick={async () => {
|
||||||
const orgCount =
|
if (!isCloud) {
|
||||||
await utils.user.checkUserOrganizations.fetch(
|
const orgCount =
|
||||||
{
|
await utils.user.checkUserOrganizations.fetch(
|
||||||
|
{
|
||||||
|
userId: member.user.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgCount === 1) {
|
||||||
|
await mutateAsync({
|
||||||
userId: member.user.id,
|
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 (!error) {
|
||||||
|
toast.success(
|
||||||
if (orgCount === 1) {
|
"User unlinked successfully",
|
||||||
await mutateAsync({
|
);
|
||||||
userId: member.user.id,
|
refetch();
|
||||||
})
|
} else {
|
||||||
.then(() => {
|
toast.error(
|
||||||
toast.success(
|
"Error unlinking user",
|
||||||
"User deleted successfully",
|
);
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error deleting user",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
|
|
||||||
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
|
||||||
</DropdownMenuItem>
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
</DialogAction>
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Unlink User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const addServerDomain = z
|
const addServerDomain = z
|
||||||
.object({
|
.object({
|
||||||
domain: z.string(),
|
domain: z.string().trim().toLowerCase(),
|
||||||
letsEncryptEmail: z.string(),
|
letsEncryptEmail: z.string(),
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
@@ -49,7 +49,11 @@ const addServerDomain = z
|
|||||||
message: "Required",
|
message: "Required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
if (
|
||||||
|
data.https &&
|
||||||
|
data.certificateType === "letsencrypt" &&
|
||||||
|
!data.letsEncryptEmail
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[400px] pr-4">
|
<ScrollArea className="pr-4">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Card key={field.id} className="bg-transparent">
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`ports.${index}.targetPort`}
|
name={`ports.${index}.targetPort`}
|
||||||
@@ -303,6 +305,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</AlertBlock>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ const getTerminalKey = () => {
|
|||||||
interface Props {
|
interface Props {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalModal = ({ children, serverId }: Props) => {
|
export const TerminalModal = ({
|
||||||
|
children,
|
||||||
|
serverId,
|
||||||
|
asButton = false,
|
||||||
|
}: Props) => {
|
||||||
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isLocalServer = serverId === "local";
|
const isLocalServer = serverId === "local";
|
||||||
|
|
||||||
const { data } = api.server.one.useQuery(
|
const { data } = api.server.one.useQuery(
|
||||||
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer space-x-3"
|
className="w-full cursor-pointer space-x-3"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-7xl"
|
className="sm:max-w-7xl"
|
||||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { ChatwootWidget } from "../shared/ChatwootWidget";
|
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
{isCloud === true && isUserSubscribed === true && (
|
{isCloud === true && isUserSubscribed === true && (
|
||||||
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
|
<>
|
||||||
|
<HubSpotWidget />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/schedules",
|
url: "/dashboard/schedules",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
// Only enabled in non-cloud environments
|
// 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,
|
isSingle: true,
|
||||||
@@ -168,7 +169,9 @@ const MENU: Menu = {
|
|||||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!(
|
||||||
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToTraefikFiles) &&
|
||||||
!isCloud
|
!isCloud
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -179,7 +182,12 @@ const MENU: Menu = {
|
|||||||
icon: BlocksIcon,
|
icon: BlocksIcon,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -188,7 +196,12 @@ const MENU: Menu = {
|
|||||||
icon: PieChart,
|
icon: PieChart,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -197,7 +210,12 @@ const MENU: Menu = {
|
|||||||
icon: Forward,
|
icon: Forward,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
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
|
// Legacy unused menu, adjusted to the new structure
|
||||||
@@ -264,7 +282,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/server",
|
url: "/dashboard/settings/server",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
// Only enabled for admins in non-cloud environments
|
// 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,
|
isSingle: true,
|
||||||
@@ -278,7 +297,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/servers",
|
url: "/dashboard/settings/servers",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -286,7 +306,8 @@ const MENU: Menu = {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
url: "/dashboard/settings/users",
|
url: "/dashboard/settings/users",
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -295,14 +316,19 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/ssh-keys",
|
url: "/dashboard/settings/ssh-keys",
|
||||||
// Only enabled for admins and users with access to SSH keys
|
// Only enabled for admins and users with access to SSH keys
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
!!(
|
||||||
|
auth?.role === "owner" ||
|
||||||
|
auth?.canAccessToSSHKeys ||
|
||||||
|
auth?.role === "admin"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI",
|
title: "AI",
|
||||||
icon: BotIcon,
|
icon: BotIcon,
|
||||||
url: "/dashboard/settings/ai",
|
url: "/dashboard/settings/ai",
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -311,7 +337,11 @@ const MENU: Menu = {
|
|||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
// Only enabled for admins and users with access to Git providers
|
// Only enabled for admins and users with access to Git providers
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
|
!!(
|
||||||
|
auth?.role === "owner" ||
|
||||||
|
auth?.canAccessToGitProviders ||
|
||||||
|
auth?.role === "admin"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -319,7 +349,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/registry",
|
url: "/dashboard/settings/registry",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -327,7 +358,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/destinations",
|
url: "/dashboard/settings/destinations",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
// Only enabled for admins
|
// 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",
|
url: "/dashboard/settings/certificates",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -344,7 +377,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/cluster",
|
url: "/dashboard/settings/cluster",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
// Only enabled for admins in non-cloud environments
|
// 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,
|
isSingle: true,
|
||||||
@@ -352,7 +386,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/notifications",
|
url: "/dashboard/settings/notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -718,7 +753,9 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(user?.role === "owner" || isCloud) && (
|
{(user?.role === "owner" ||
|
||||||
|
user?.role === "admin" ||
|
||||||
|
isCloud) && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AddOrganization />
|
<AddOrganization />
|
||||||
@@ -1082,7 +1119,7 @@ export default function Page({ children }: Props) {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu className="flex flex-col gap-2">
|
<SidebarMenu className="flex flex-col gap-2">
|
||||||
{!isCloud && auth?.role === "owner" && (
|
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<UpdateServerButton />
|
<UpdateServerButton />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export const UserNav = () => {
|
|||||||
alt={data?.user?.image || ""}
|
alt={data?.user?.image || ""}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(data?.user?.name)}
|
{getFallbackAvatarInitials(
|
||||||
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||||
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
@@ -102,7 +104,9 @@ export const UserNav = () => {
|
|||||||
>
|
>
|
||||||
Monitoring
|
Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
|
{(data?.role === "owner" ||
|
||||||
|
data?.role === "admin" ||
|
||||||
|
data?.canAccessToTraefikFiles) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -112,7 +116,9 @@ export const UserNav = () => {
|
|||||||
Traefik
|
Traefik
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(data?.role === "owner" || data?.canAccessToDocker) && (
|
{(data?.role === "owner" ||
|
||||||
|
data?.role === "admin" ||
|
||||||
|
data?.canAccessToDocker) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -126,7 +132,7 @@ export const UserNav = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
data?.role === "owner" && (
|
(data?.role === "owner" || data?.role === "admin") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
export const HubSpotWidget = () => {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
id="hs-script-loader"
|
||||||
|
type="text/javascript"
|
||||||
|
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +1,36 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbPage,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
|
||||||
interface Props {
|
interface BreadcrumbEntry {
|
||||||
list: {
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
dropdownItems?: {
|
||||||
name: string;
|
name: string;
|
||||||
href?: string;
|
href: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list: BreadcrumbEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export const BreadcrumbSidebar = ({ list }: Props) => {
|
export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||||
@@ -29,13 +43,29 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
|
|||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<Fragment key={item.name}>
|
<Fragment key={item.name}>
|
||||||
<BreadcrumbItem className="block">
|
<BreadcrumbItem className="block">
|
||||||
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
{item.dropdownItems && item.dropdownItems.length > 0 ? (
|
||||||
{item.href ? (
|
<DropdownMenu>
|
||||||
<Link href={item?.href}>{item?.name}</Link>
|
<DropdownMenuTrigger className="flex items-center gap-1 hover:text-foreground transition-colors outline-none">
|
||||||
) : (
|
{item.name}
|
||||||
item?.name
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
)}
|
</DropdownMenuTrigger>
|
||||||
</BreadcrumbLink>
|
<DropdownMenuContent align="start">
|
||||||
|
{item.dropdownItems.map((subItem) => (
|
||||||
|
<DropdownMenuItem key={subItem.href} asChild>
|
||||||
|
<Link href={subItem.href}>{subItem.name}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
||||||
|
{item.href ? (
|
||||||
|
<Link href={item?.href}>{item?.name}</Link>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbPage>{item?.name}</BreadcrumbPage>
|
||||||
|
)}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{index + 1 < list.length && (
|
{index + 1 < list.length && (
|
||||||
<BreadcrumbSeparator className="block" />
|
<BreadcrumbSeparator className="block" />
|
||||||
|
|||||||
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;
|
||||||
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;
|
||||||
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
-- Set isDefault to true for existing production environments
|
||||||
|
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';
|
||||||
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
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -876,6 +876,62 @@
|
|||||||
"when": 1764571454170,
|
"when": 1764571454170,
|
||||||
"tag": "0124_certain_cloak",
|
"tag": "0124_certain_cloak",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 131,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765342621312,
|
||||||
|
"tag": "0131_volatile_beast",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 132,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765346573500,
|
||||||
|
"tag": "0132_clean_layla_miller",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
apiKeyClient,
|
apiKeyClient,
|
||||||
|
inferAdditionalFields,
|
||||||
organizationClient,
|
organizationClient,
|
||||||
twoFactorClient,
|
twoFactorClient,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
@@ -13,5 +14,12 @@ export const authClient = createAuthClient({
|
|||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
apiKeyClient(),
|
apiKeyClient(),
|
||||||
adminClient(),
|
adminClient(),
|
||||||
|
inferAdditionalFields({
|
||||||
|
user: {
|
||||||
|
lastName: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.25.11",
|
"version": "v0.26.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||||
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
|
|
||||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||||
"migration:run": "tsx -r dotenv/config migration.ts",
|
"migration:run": "tsx -r dotenv/config migration.ts",
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
"docker:build:canary": "./docker/build.sh canary",
|
"docker:build:canary": "./docker/build.sh canary",
|
||||||
"docker:push:canary": "./docker/push.sh canary",
|
"docker:push:canary": "./docker/push.sh canary",
|
||||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||||
"test": "vitest --config __test__/vitest.config.ts",
|
"test": "vitest --config __test__/vitest.config.ts volume-backups",
|
||||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -118,7 +117,7 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^15.3.2",
|
"next": "^16.0.10",
|
||||||
"next-i18next": "^15.4.2",
|
"next-i18next": "^15.4.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { NextPage } from "next";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Script from "next/script";
|
|
||||||
import { appWithTranslation } from "next-i18next";
|
import { appWithTranslation } from "next-i18next";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
@@ -43,14 +42,6 @@ const MyApp = ({
|
|||||||
<Head>
|
<Head>
|
||||||
<title>Dokploy</title>
|
<title>Dokploy</title>
|
||||||
</Head>
|
</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
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -81,41 +81,34 @@ export default async function handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhookImageName) {
|
// If webhook provides image information, validate it matches the configured image
|
||||||
res.status(301).json({
|
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
|
||||||
message: "Webhook Docker Image Name Not Found",
|
if (webhookImageName) {
|
||||||
});
|
// Validate image name matches
|
||||||
return;
|
if (webhookImageName !== applicationImageName) {
|
||||||
}
|
res.status(301).json({
|
||||||
|
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate image name matches
|
if (!applicationDockerTag) {
|
||||||
if (webhookImageName !== applicationImageName) {
|
res.status(301).json({
|
||||||
res.status(301).json({
|
message: "Application Docker Tag Not Found",
|
||||||
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!applicationDockerTag) {
|
if (webhookDockerTag) {
|
||||||
res.status(301).json({
|
if (webhookDockerTag !== applicationDockerTag) {
|
||||||
message: "Application Docker Tag Not Found",
|
res.status(301).json({
|
||||||
});
|
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||||
return;
|
});
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (!webhookDockerTag) {
|
}
|
||||||
res.status(301).json({
|
|
||||||
message: "Webhook Docker Tag Not Found",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookDockerTag !== applicationDockerTag) {
|
|
||||||
res.status(301).json({
|
|
||||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
||||||
} else if (sourceType === "github") {
|
} else if (sourceType === "github") {
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap(
|
||||||
(commit: any) => commit.modified,
|
(commit: any) => commit.modified,
|
||||||
@@ -249,17 +242,19 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && application.serverId) {
|
if (IS_CLOUD && application.serverId) {
|
||||||
jobData.serverId = application.serverId;
|
jobData.serverId = application.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
return true;
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
|
||||||
"deployments",
|
|
||||||
{ ...jobData },
|
|
||||||
{
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Application", error });
|
res.status(400).json({ message: "Error deploying Application", error });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -179,17 +179,19 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && composeResult.serverId) {
|
if (IS_CLOUD && composeResult.serverId) {
|
||||||
jobData.serverId = composeResult.serverId;
|
jobData.serverId = composeResult.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
return true;
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
|
||||||
"deployments",
|
|
||||||
{ ...jobData },
|
|
||||||
{
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Compose", error });
|
res.status(400).json({ message: "Error deploying Compose", error });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -165,7 +167,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && composeApp.serverId) {
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
jobData.serverId = composeApp.serverId;
|
jobData.serverId = composeApp.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +250,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -291,7 +297,9 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
if (IS_CLOUD && composeApp.serverId) {
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
jobData.serverId = composeApp.serverId;
|
jobData.serverId = composeApp.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +499,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
|
|||||||
@@ -279,6 +279,16 @@ const EnvironmentPage = (
|
|||||||
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
|
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
|
||||||
const { projectId, environmentId } = props;
|
const { projectId, environmentId } = props;
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: projectId,
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<string>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
||||||
@@ -863,6 +873,7 @@ const EnvironmentPage = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: currentEnvironment.name,
|
name: currentEnvironment.name,
|
||||||
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ const Service = (
|
|||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.project?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
<UseKeyboardNav forPage="application" />
|
<UseKeyboardNav forPage="application" />
|
||||||
@@ -98,11 +107,11 @@ const Service = (
|
|||||||
list={[
|
list={[
|
||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment.project.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ const Service = (
|
|||||||
|
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -92,7 +100,7 @@ const Service = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ const Mariadb = (
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
<UseKeyboardNav forPage="mariadb" />
|
<UseKeyboardNav forPage="mariadb" />
|
||||||
@@ -73,7 +82,7 @@ const Mariadb = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ const Mongo = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -73,7 +81,7 @@ const Mongo = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const MySql = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -72,7 +80,7 @@ const MySql = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const Postgresql = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -72,7 +80,7 @@ const Postgresql = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const Redis = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -72,7 +80,7 @@ const Redis = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function getServerSideProps(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { user } = await validateRequest(ctx.req);
|
const { user } = await validateRequest(ctx.req);
|
||||||
if (!user || user.role !== "owner") {
|
if (!user || (user.role !== "owner" && user.role !== "admin")) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function getServerSideProps(
|
|||||||
}
|
}
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
if (!user || user.role === "member") {
|
if (!user || user.role !== "owner") {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const Page = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
|
{(data?.canAccessToAPI ||
|
||||||
|
data?.role === "owner" ||
|
||||||
|
data?.role === "admin") && <ShowApiKeys />}
|
||||||
|
|
||||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import { api } from "@/utils/api";
|
|||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, {
|
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
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -92,6 +95,7 @@ const Invitation = ({
|
|||||||
const form = useForm<Register>({
|
const form = useForm<Register>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -115,6 +119,7 @@ const Invitation = ({
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
lastName: values.lastName,
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
headers: {
|
headers: {
|
||||||
"x-dokploy-token": token,
|
"x-dokploy-token": token,
|
||||||
@@ -197,12 +202,22 @@ const Invitation = ({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="John" {...field} />
|
||||||
placeholder="Enter your name"
|
</FormControl>
|
||||||
{...field}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Doe" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, {
|
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
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -79,6 +82,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
const form = useForm<Register>({
|
const form = useForm<Register>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -95,6 +99,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
lastName: values.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -158,9 +163,22 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { findAdmin } from "@dokploy/server";
|
import { findOwner } from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { user } from "@dokploy/server/db/schema";
|
import { user } from "@dokploy/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await findAdmin();
|
const result = await findOwner();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(user)
|
.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 { db } from "@dokploy/server/db";
|
||||||
import { account } from "@dokploy/server/db/schema";
|
import { account } from "@dokploy/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
|||||||
try {
|
try {
|
||||||
const randomPassword = await generateRandomPassword();
|
const randomPassword = await generateRandomPassword();
|
||||||
|
|
||||||
const result = await findAdmin();
|
const result = await findOwner();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(account)
|
.update(account)
|
||||||
|
|||||||
@@ -336,7 +336,9 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (IS_CLOUD && application.serverId) {
|
if (IS_CLOUD && application.serverId) {
|
||||||
jobData.serverId = application.serverId;
|
jobData.serverId = application.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -365,6 +367,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
env: input.env,
|
env: input.env,
|
||||||
buildArgs: input.buildArgs,
|
buildArgs: input.buildArgs,
|
||||||
buildSecrets: input.buildSecrets,
|
buildSecrets: input.buildSecrets,
|
||||||
|
createEnvFile: input.createEnvFile,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
@@ -700,7 +703,9 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
if (IS_CLOUD && application.serverId) {
|
if (IS_CLOUD && application.serverId) {
|
||||||
jobData.serverId = application.serverId;
|
jobData.serverId = application.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -812,7 +817,9 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,7 +417,9 @@ export const composeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (IS_CLOUD && compose.serverId) {
|
if (IS_CLOUD && compose.serverId) {
|
||||||
jobData.serverId = compose.serverId;
|
jobData.serverId = compose.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -453,7 +455,9 @@ export const composeRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
if (IS_CLOUD && compose.serverId) {
|
if (IS_CLOUD && compose.serverId) {
|
||||||
jobData.serverId = compose.serverId;
|
jobData.serverId = compose.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
if (input.name === "production") {
|
if (input.name === "production") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Environment name cannot be production",
|
message:
|
||||||
|
"You cannot create a environment with the name 'production'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow users to create environments with any name, including "production"
|
||||||
const environment = await createEnvironment(input);
|
const environment = await createEnvironment(input);
|
||||||
|
|
||||||
if (ctx.user.role === "member") {
|
if (ctx.user.role === "member") {
|
||||||
@@ -206,6 +208,14 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent deletion of the default environment
|
||||||
|
if (environment.isDefault) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You cannot delete the default environment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check environment deletion permission
|
// Check environment deletion permission
|
||||||
await checkEnvironmentDeletionPermission(
|
await checkEnvironmentDeletionPermission(
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
@@ -243,13 +253,7 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const { environmentId, ...updateData } = input;
|
const { environmentId, ...updateData } = input;
|
||||||
|
|
||||||
if (updateData.name === "production") {
|
// Allow users to rename environments to any name, including "production"
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Environment name cannot be production",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.user.role === "member") {
|
if (ctx.user.role === "member") {
|
||||||
await checkEnvironmentAccess(
|
await checkEnvironmentAccess(
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
@@ -259,6 +263,14 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const currentEnvironment = await findEnvironmentById(environmentId);
|
const currentEnvironment = await findEnvironmentById(environmentId);
|
||||||
|
|
||||||
|
// Prevent renaming the default environment, but allow updating env and description
|
||||||
|
if (currentEnvironment.isDefault && updateData.name !== undefined) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You cannot rename the default environment",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
currentEnvironment.project.organizationId !==
|
currentEnvironment.project.organizationId !==
|
||||||
ctx.session.activeOrganizationId
|
ctx.session.activeOrganizationId
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
createMount,
|
createMount,
|
||||||
deployMariadb,
|
deployMariadb,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findMariadbById,
|
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMariadbById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -87,7 +87,7 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
type: "volume",
|
type: "volume",
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return newMariadb;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
createMount,
|
createMount,
|
||||||
deployMongo,
|
deployMongo,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findMongoById,
|
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMongoById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -87,7 +87,7 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
type: "volume",
|
type: "volume",
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return newMongo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
type: "volume",
|
type: "volume",
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return newMysql;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
createCustomNotification,
|
||||||
createDiscordNotification,
|
createDiscordNotification,
|
||||||
createEmailNotification,
|
createEmailNotification,
|
||||||
createGotifyNotification,
|
createGotifyNotification,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
findNotificationById,
|
findNotificationById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeNotificationById,
|
removeNotificationById,
|
||||||
|
sendCustomNotification,
|
||||||
sendDiscordNotification,
|
sendDiscordNotification,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
|
updateCustomNotification,
|
||||||
updateDiscordNotification,
|
updateDiscordNotification,
|
||||||
updateEmailNotification,
|
updateEmailNotification,
|
||||||
updateGotifyNotification,
|
updateGotifyNotification,
|
||||||
@@ -36,6 +39,7 @@ import {
|
|||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import {
|
import {
|
||||||
|
apiCreateCustom,
|
||||||
apiCreateDiscord,
|
apiCreateDiscord,
|
||||||
apiCreateEmail,
|
apiCreateEmail,
|
||||||
apiCreateGotify,
|
apiCreateGotify,
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
|
apiTestCustomConnection,
|
||||||
apiTestDiscordConnection,
|
apiTestDiscordConnection,
|
||||||
apiTestEmailConnection,
|
apiTestEmailConnection,
|
||||||
apiTestGotifyConnection,
|
apiTestGotifyConnection,
|
||||||
@@ -51,6 +56,7 @@ import {
|
|||||||
apiTestNtfyConnection,
|
apiTestNtfyConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
|
apiUpdateCustom,
|
||||||
apiUpdateDiscord,
|
apiUpdateDiscord,
|
||||||
apiUpdateEmail,
|
apiUpdateEmail,
|
||||||
apiUpdateGotify,
|
apiUpdateGotify,
|
||||||
@@ -334,6 +340,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
gotify: true,
|
gotify: true,
|
||||||
ntfy: true,
|
ntfy: true,
|
||||||
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
},
|
},
|
||||||
orderBy: desc(notifications.createdAt),
|
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
|
createLark: adminProcedure
|
||||||
.input(apiCreateLark)
|
.input(apiCreateLark)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can create an organization",
|
message: "Only the organization owner can create an organization",
|
||||||
@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.returning()
|
.returning()
|
||||||
.then((res) => res[0]);
|
.then((res) => res[0]);
|
||||||
|
|
||||||
console.log("result", result);
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
@@ -82,7 +80,22 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
organizationId: z.string(),
|
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({
|
return await db.query.organization.findFirst({
|
||||||
where: eq(organization.id, input.organizationId),
|
where: eq(organization.id, input.organizationId),
|
||||||
});
|
});
|
||||||
@@ -96,12 +109,45 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can update it",
|
message: "Only the organization owner can update it",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(organization)
|
.update(organization)
|
||||||
.set({
|
.set({
|
||||||
@@ -119,12 +165,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
// First, verify the organization exists
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Only the organization owner can delete it",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const org = await db.query.organization.findFirst({
|
const org = await db.query.organization.findFirst({
|
||||||
where: eq(organization.id, input.organizationId),
|
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({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can delete it",
|
message: "Only the organization owner can delete it",
|
||||||
@@ -194,6 +255,65 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.delete(invitation)
|
.delete(invitation)
|
||||||
.where(eq(invitation.id, input.invitationId));
|
.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
|
setDefault: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
type: "volume",
|
type: "volume",
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return newPostgres;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
apiFindOneRegistry,
|
apiFindOneRegistry,
|
||||||
apiRemoveRegistry,
|
apiRemoveRegistry,
|
||||||
apiTestRegistry,
|
apiTestRegistry,
|
||||||
|
apiTestRegistryById,
|
||||||
apiUpdateRegistry,
|
apiUpdateRegistry,
|
||||||
registry,
|
registry,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
@@ -109,6 +110,67 @@ export const registryRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Error testing the registry",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
testRegistryById: protectedProcedure
|
||||||
|
.input(apiTestRegistryById)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
// Get the full registry with password from database
|
||||||
|
const registryData = await db.query.registry.findFirst({
|
||||||
|
where: eq(registry.registryId, input.registryId ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registryData) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Registry not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registryData.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not allowed to test this registry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"login",
|
||||||
|
registryData.registryUrl,
|
||||||
|
"--username",
|
||||||
|
registryData.username,
|
||||||
|
"--password-stdin",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (IS_CLOUD && !input.serverId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Select a server to test the registry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.serverId && input.serverId !== "none") {
|
||||||
|
await execAsyncRemote(
|
||||||
|
input.serverId,
|
||||||
|
`echo ${registryData.password} | docker ${args.join(" ")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await execFileAsync("docker", args, {
|
||||||
|
input: Buffer.from(registryData.password).toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
scheduleId: newSchedule.scheduleId,
|
scheduleId: newSchedule.scheduleId,
|
||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: newSchedule.cronExpression,
|
cronSchedule: newSchedule.cronExpression,
|
||||||
|
timezone: newSchedule.timezone,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
scheduleJob(newSchedule);
|
scheduleJob(newSchedule);
|
||||||
@@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
scheduleId: updatedSchedule.scheduleId,
|
scheduleId: updatedSchedule.scheduleId,
|
||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: updatedSchedule.cronExpression,
|
cronSchedule: updatedSchedule.cronExpression,
|
||||||
|
timezone: updatedSchedule.timezone,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await removeJob({
|
await removeJob({
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
canAccessToTraefikFiles,
|
canAccessToTraefikFiles,
|
||||||
checkGPUStatus,
|
checkGPUStatus,
|
||||||
cleanStoppedContainers,
|
checkPortInUse,
|
||||||
cleanUpDockerBuilder,
|
cleanupAll,
|
||||||
cleanUpSystemPrune,
|
cleanupAllBackground,
|
||||||
cleanUpUnusedImages,
|
cleanupBuilders,
|
||||||
cleanUpUnusedVolumes,
|
cleanupContainers,
|
||||||
|
cleanupImages,
|
||||||
|
cleanupSystem,
|
||||||
|
cleanupVolumes,
|
||||||
DEFAULT_UPDATE_DATA,
|
DEFAULT_UPDATE_DATA,
|
||||||
execAsync,
|
execAsync,
|
||||||
findServerById,
|
findServerById,
|
||||||
@@ -130,6 +133,17 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
let newPorts = ports;
|
let newPorts = ports;
|
||||||
// If receive true, add 8080 to ports
|
// If receive true, add 8080 to ports
|
||||||
if (input.enableDashboard) {
|
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({
|
newPorts.push({
|
||||||
targetPort: 8080,
|
targetPort: 8080,
|
||||||
publishedPort: 8080,
|
publishedPort: 8080,
|
||||||
@@ -149,43 +163,41 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
cleanUnusedImages: adminProcedure
|
cleanUnusedImages: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedImages(input?.serverId);
|
await cleanupImages(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanUnusedVolumes: adminProcedure
|
cleanUnusedVolumes: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedVolumes(input?.serverId);
|
await cleanupVolumes(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanStoppedContainers: adminProcedure
|
cleanStoppedContainers: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanStoppedContainers(input?.serverId);
|
await cleanupContainers(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanDockerBuilder: adminProcedure
|
cleanDockerBuilder: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
await cleanupBuilders(input?.serverId);
|
||||||
}),
|
}),
|
||||||
cleanDockerPrune: adminProcedure
|
cleanDockerPrune: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpSystemPrune(input?.serverId);
|
await cleanupSystem(input?.serverId);
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
await cleanupBuilders(input?.serverId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanAll: adminProcedure
|
cleanAll: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedImages(input?.serverId);
|
// Execute cleanup in background and return immediately to avoid gateway timeouts
|
||||||
await cleanStoppedContainers(input?.serverId);
|
const result = await cleanupAllBackground(input?.serverId);
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
|
||||||
await cleanUpSystemPrune(input?.serverId);
|
|
||||||
|
|
||||||
return true;
|
return result;
|
||||||
}),
|
}),
|
||||||
cleanMonitoring: adminProcedure.mutation(async () => {
|
cleanMonitoring: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
@@ -201,7 +213,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.id, {
|
await updateUser(ctx.user.ownerId, {
|
||||||
sshPrivateKey: input.sshPrivateKey,
|
sshPrivateKey: input.sshPrivateKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,11 +225,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const user = await updateUser(ctx.user.id, {
|
const user = await updateUser(ctx.user.ownerId, {
|
||||||
host: input.host,
|
host: input.host,
|
||||||
...(input.letsEncryptEmail && {
|
letsEncryptEmail: input.letsEncryptEmail,
|
||||||
letsEncryptEmail: input.letsEncryptEmail,
|
|
||||||
}),
|
|
||||||
certificateType: input.certificateType,
|
certificateType: input.certificateType,
|
||||||
https: input.https,
|
https: input.https,
|
||||||
});
|
});
|
||||||
@@ -240,7 +250,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.id, {
|
await updateUser(ctx.user.ownerId, {
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -281,9 +291,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
console.log(
|
console.log(
|
||||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||||
);
|
);
|
||||||
await cleanUpUnusedImages(server.serverId);
|
|
||||||
await cleanUpDockerBuilder(server.serverId);
|
await cleanupAll(server.serverId);
|
||||||
await cleanUpSystemPrune(server.serverId);
|
|
||||||
await sendDockerCleanupNotifications(server.organizationId);
|
await sendDockerCleanupNotifications(server.organizationId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,7 +310,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!IS_CLOUD) {
|
} else if (!IS_CLOUD) {
|
||||||
const userUpdated = await updateUser(ctx.user.id, {
|
const userUpdated = await updateUser(ctx.user.ownerId, {
|
||||||
enableDockerCleanup: input.enableDockerCleanup,
|
enableDockerCleanup: input.enableDockerCleanup,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,9 +319,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
console.log(
|
console.log(
|
||||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||||
);
|
);
|
||||||
await cleanUpUnusedImages();
|
|
||||||
await cleanUpDockerBuilder();
|
await cleanupAll();
|
||||||
await cleanUpSystemPrune();
|
|
||||||
await sendDockerCleanupNotifications(
|
await sendDockerCleanupNotifications(
|
||||||
ctx.session.activeOrganizationId,
|
ctx.session.activeOrganizationId,
|
||||||
);
|
);
|
||||||
@@ -810,6 +820,19 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
"dokploy-traefik",
|
"dokploy-traefik",
|
||||||
input?.serverId,
|
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);
|
const preparedEnv = prepareEnvironmentVariables(env);
|
||||||
|
|
||||||
await writeTraefikSetup({
|
await writeTraefikSetup({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user