mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 14:15:21 +02:00
Compare commits
146 Commits
v0.26.2
...
core-model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b965dedd7d | ||
|
|
2b779f9fc6 | ||
|
|
15b0ca7ab2 | ||
|
|
fd6f61fd2a | ||
|
|
8f95546535 | ||
|
|
8b370d4f7b | ||
|
|
1ed941b17c | ||
|
|
18d980c3ff | ||
|
|
5ddcdd843c | ||
|
|
fdf88b1ff3 | ||
|
|
13b64e45ec | ||
|
|
4383e46686 | ||
|
|
60d69d2915 | ||
|
|
a2b16d4be8 | ||
|
|
831a1815cf | ||
|
|
6b9bcbc539 | ||
|
|
6ca6ff3530 | ||
|
|
7583d5f860 | ||
|
|
7921f754fd | ||
|
|
0c0944d221 | ||
|
|
d490111a58 | ||
|
|
167daccee0 | ||
|
|
11af6a5eb9 | ||
|
|
85424badcf | ||
|
|
ccfd7f5189 | ||
|
|
6d94da1dee | ||
|
|
10c0de9d5f | ||
|
|
2b0ae65f71 | ||
|
|
2acaaede37 | ||
|
|
f303962319 | ||
|
|
edc8efe816 | ||
|
|
4e0cb2a9c7 | ||
|
|
4001f1d067 | ||
|
|
d894b2a3bf | ||
|
|
14d359dd14 | ||
|
|
1e11f603de | ||
|
|
d12f029e2b | ||
|
|
0c62bc0f29 | ||
|
|
b19d3e94eb | ||
|
|
5005f9198b | ||
|
|
fe5efd7651 | ||
|
|
8db7a421dc | ||
|
|
068deecb61 | ||
|
|
9aa03efd13 | ||
|
|
016aa0248a | ||
|
|
eb9d140c5d | ||
|
|
2eb73b988b | ||
|
|
d2ce587494 | ||
|
|
13ad8cb846 | ||
|
|
0897417d7c | ||
|
|
eb14a68bdd | ||
|
|
01c0b461b5 | ||
|
|
9498fbeff3 | ||
|
|
d2aa60ddf7 | ||
|
|
58b75205af | ||
|
|
9e03625586 | ||
|
|
260efdc2bb | ||
|
|
1b5bfe051d | ||
|
|
e4384075f2 | ||
|
|
b355d44605 | ||
|
|
f39aa23803 | ||
|
|
3abc4cdc3b | ||
|
|
ec56062f17 | ||
|
|
10c4f882a5 | ||
|
|
f1dfa9c6a2 | ||
|
|
6010643d9e | ||
|
|
1ccb205495 | ||
|
|
b2be5bc09f | ||
|
|
babd30a110 | ||
|
|
e77f276785 | ||
|
|
78c9a047b0 | ||
|
|
84e0f5856b | ||
|
|
2bfa4643fc | ||
|
|
8c7bc82712 | ||
|
|
44645a6fbe | ||
|
|
771d0dd8ab | ||
|
|
67725759e6 | ||
|
|
2065372d4f | ||
|
|
67d5e1a350 | ||
|
|
93fa19213e | ||
|
|
1988a14b24 | ||
|
|
3bdf029155 | ||
|
|
e1896c2498 | ||
|
|
a8064afd60 | ||
|
|
3849a206e8 | ||
|
|
69d5c6f0cb | ||
|
|
bb0a53d976 | ||
|
|
0a8753d0a9 | ||
|
|
23b14cf0cf | ||
|
|
53f67c6eb2 | ||
|
|
7c53a3ef75 | ||
|
|
c065c85ee6 | ||
|
|
db97de2a39 | ||
|
|
dc7af1b840 | ||
|
|
97362da2ae | ||
|
|
b476e50ff1 | ||
|
|
1b22384315 | ||
|
|
6685bd618e | ||
|
|
f5d334244a | ||
|
|
fd084c6d37 | ||
|
|
e607220bfa | ||
|
|
d8514b067b | ||
|
|
0590e78854 | ||
|
|
27fa0e881a | ||
|
|
72f2cc6268 | ||
|
|
854bd88e0a | ||
|
|
acf385a1f3 | ||
|
|
d1bc109697 | ||
|
|
38c7e1e996 | ||
|
|
54d5266573 | ||
|
|
3a5ac9d31f | ||
|
|
0ddf6b851f | ||
|
|
ed701df6ac | ||
|
|
dfc15cd621 | ||
|
|
1ac3d1c1b0 | ||
|
|
f6b756e711 | ||
|
|
9f84dd4e0d | ||
|
|
2e32b0a4af | ||
|
|
0f69bbbd20 | ||
|
|
9e79314ef4 | ||
|
|
540b4039ac | ||
|
|
9e89edf167 | ||
|
|
e31d5a723b | ||
|
|
eb4fbff1b2 | ||
|
|
3aeb52810c | ||
|
|
8eaf2ab5c7 | ||
|
|
5ebcbf86ea | ||
|
|
67f4ca2cd9 | ||
|
|
6bb5404f87 | ||
|
|
3e356e6890 | ||
|
|
b65f53d141 | ||
|
|
2b1a3db7b8 | ||
|
|
b66156956a | ||
|
|
669de0f95f | ||
|
|
371cf83e52 | ||
|
|
51abf49458 | ||
|
|
72cc7a2d2c | ||
|
|
ba5283039c | ||
|
|
19a7a80d43 | ||
|
|
5d42737943 | ||
|
|
4c10056394 | ||
|
|
d875e08d48 | ||
|
|
c045c5328f | ||
|
|
8c889fc71e | ||
|
|
d465fb4da1 | ||
|
|
698104e7b7 |
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
|||||||
- name: Install Nixpacks
|
- name: Install Nixpacks
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export NIXPACKS_VERSION=1.39.0
|
export NIXPACKS_VERSION=1.41.0
|
||||||
curl -sSL https://nixpacks.com/install.sh | bash
|
curl -sSL https://nixpacks.com/install.sh | bash
|
||||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||||
|
|
||||||
- name: Install Railpack
|
- name: Install Railpack
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export RAILPACK_VERSION=0.15.0
|
export RAILPACK_VERSION=0.15.4
|
||||||
curl -sSL https://railpack.com/install.sh | bash
|
curl -sSL https://railpack.com/install.sh | bash
|
||||||
echo "Railpack installed $RAILPACK_VERSION"
|
echo "Railpack installed $RAILPACK_VERSION"
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,4 +43,7 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
|
# Development environment
|
||||||
|
.devcontainer
|
||||||
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|||||||
@@ -51,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
|
|||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.39.0
|
ARG NIXPACKS_VERSION=1.41.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.2.2
|
ARG RAILPACK_VERSION=0.15.4
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
|||||||
RUN pnpm install -g tsx
|
RUN pnpm install -g tsx
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.3.0",
|
||||||
"@nerimity/mimiqueue": "1.2.3",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
titleLog: z.string().optional(),
|
titleLog: z.string().optional(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string().optional(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.applicationId,
|
||||||
|
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
|
|||||||
@@ -206,4 +206,38 @@ describe("getRegistryTag", () => {
|
|||||||
expect(result).toBe("docker.io/myuser/repo");
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("special characters in username", () => {
|
||||||
|
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$library+dokploy",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with $ and other special characters", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$test+app",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myapp:latest");
|
||||||
|
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with multiple $ symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "user$name$test",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "app");
|
||||||
|
expect(result).toBe("docker.io/user$name$test/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with + and - symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot+test-user",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Domain } from "@dokploy/server";
|
import type { Domain } from "@dokploy/server";
|
||||||
import { createDomainLabels } from "@dokploy/server";
|
import { createDomainLabels } from "@dokploy/server";
|
||||||
import { parse, stringify } from "yaml";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for Traefik Host rule label format.
|
* Regression tests for Traefik Host rule label format.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
createEnvFile: true,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ vi.mock("node:fs", () => ({
|
|||||||
default: fs,
|
default: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { FileConfig, User } from "@dokploy/server";
|
import type { FileConfig } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
loadOrCreateConfig,
|
loadOrCreateConfig,
|
||||||
updateServerTraefik,
|
updateServerTraefik,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const baseAdmin: User = {
|
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||||
|
|
||||||
|
const baseSettings: WebServerSettings = {
|
||||||
|
id: "",
|
||||||
https: false,
|
https: false,
|
||||||
enablePaidFeatures: false,
|
certificateType: "none",
|
||||||
allowImpersonation: false,
|
host: null,
|
||||||
role: "user",
|
serverIp: null,
|
||||||
firstName: "",
|
letsEncryptEmail: null,
|
||||||
lastName: "",
|
sshPrivateKey: null,
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -45,29 +51,8 @@ const baseAdmin: User = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
createdAt: new Date(),
|
createdAt: null,
|
||||||
serverIp: null,
|
|
||||||
certificateType: "none",
|
|
||||||
host: null,
|
|
||||||
letsEncryptEmail: null,
|
|
||||||
sshPrivateKey: null,
|
|
||||||
enableDockerCleanup: false,
|
|
||||||
logCleanupCron: null,
|
|
||||||
serversQuantity: 0,
|
|
||||||
stripeCustomerId: "",
|
|
||||||
stripeSubscriptionId: "",
|
|
||||||
banExpires: new Date(),
|
|
||||||
banned: true,
|
|
||||||
banReason: "",
|
|
||||||
email: "",
|
|
||||||
expirationDate: "",
|
|
||||||
id: "",
|
|
||||||
isRegistered: false,
|
|
||||||
createdAt2: new Date().toISOString(),
|
|
||||||
emailVerified: false,
|
|
||||||
image: "",
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
twoFactorEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -85,7 +70,7 @@ test("Should read the configuration file", () => {
|
|||||||
test("Should apply redirect-to-https", () => {
|
test("Should apply redirect-to-https", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseAdmin,
|
...baseSettings,
|
||||||
https: true,
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
@@ -100,7 +85,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Should change only host when no certificate", () => {
|
test("Should change only host when no certificate", () => {
|
||||||
updateServerTraefik(baseAdmin, "example.com");
|
updateServerTraefik(baseSettings, "example.com");
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -110,7 +95,7 @@ test("Should change only host when no certificate", () => {
|
|||||||
test("Should not touch config without host", () => {
|
test("Should not touch config without host", () => {
|
||||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
updateServerTraefik(baseAdmin, null);
|
updateServerTraefik(baseSettings, null);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -119,11 +104,14 @@ test("Should not touch config without host", () => {
|
|||||||
|
|
||||||
test("Should remove websecure if https rollback to http", () => {
|
test("Should remove websecure if https rollback to http", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||||
"example.com",
|
"example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
updateServerTraefik(
|
||||||
|
{ ...baseSettings, certificateType: "none" },
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import {
|
||||||
|
createConverter,
|
||||||
|
NumberInputWithSteps,
|
||||||
|
} from "@/components/ui/number-input";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -30,6 +33,23 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const CPU_STEP = 0.25;
|
||||||
|
const MEMORY_STEP_MB = 256;
|
||||||
|
|
||||||
|
const formatNumber = (value: number, decimals = 2): string =>
|
||||||
|
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
||||||
|
|
||||||
|
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
||||||
|
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||||
|
if (mb <= 0) return "";
|
||||||
|
return mb >= 1024
|
||||||
|
? `${formatNumber(mb / 1024)} GB`
|
||||||
|
: `${formatNumber(mb)} MB`;
|
||||||
|
});
|
||||||
|
|
||||||
const addResourcesSchema = z.object({
|
const addResourcesSchema = z.object({
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
cpuLimit: z.string().optional(),
|
cpuLimit: z.string().optional(),
|
||||||
@@ -51,6 +71,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory hard limit in bytes. Example: 1GB =
|
Memory hard limit in bytes. Example: 1GB =
|
||||||
1073741824 bytes
|
1073741824 bytes. Use +/- buttons to adjust by
|
||||||
|
256 MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="1073741824 (1GB in bytes)"
|
placeholder="1073741824 (1GB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory soft limit in bytes. Example: 256MB =
|
Memory soft limit in bytes. Example: 256MB =
|
||||||
268435456 bytes
|
268435456 bytes. Use +/- buttons to adjust by 256
|
||||||
|
MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="268435456 (256MB in bytes)"
|
placeholder="268435456 (256MB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||||
CPUs = 2000000000
|
CPUs = 2000000000. Use +/- buttons to adjust by
|
||||||
|
0.25 CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="2000000000 (2 CPUs)"
|
placeholder="2000000000 (2 CPUs)"
|
||||||
{...field}
|
step={CPU_STEP}
|
||||||
value={field.value?.toString() || ""}
|
converter={cpuConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU shares (relative weight). Example: 1 CPU =
|
CPU shares (relative weight). Example: 1 CPU =
|
||||||
1000000000
|
1000000000. Use +/- buttons to adjust by 0.25
|
||||||
|
CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="1000000000 (1 CPU)"
|
||||||
|
step={CPU_STEP}
|
||||||
|
converter={cpuConverter}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
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,8 +20,39 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
|
export const RAILPACK_VERSIONS = [
|
||||||
|
"0.15.4",
|
||||||
|
"0.15.3",
|
||||||
|
"0.15.2",
|
||||||
|
"0.15.1",
|
||||||
|
"0.15.0",
|
||||||
|
"0.14.0",
|
||||||
|
"0.13.0",
|
||||||
|
"0.12.0",
|
||||||
|
"0.11.0",
|
||||||
|
"0.10.0",
|
||||||
|
"0.9.2",
|
||||||
|
"0.9.1",
|
||||||
|
"0.9.0",
|
||||||
|
"0.8.0",
|
||||||
|
"0.7.0",
|
||||||
|
"0.6.0",
|
||||||
|
"0.5.0",
|
||||||
|
"0.4.0",
|
||||||
|
"0.3.0",
|
||||||
|
"0.2.2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.static),
|
buildType: z.literal(BuildType.static),
|
||||||
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
|
const railpackVersion = form.watch("railpackVersion");
|
||||||
|
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -163,6 +196,14 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
form.reset(resetData(typedData));
|
form.reset(resetData(typedData));
|
||||||
|
|
||||||
|
// Check if railpack version is manual (not in the predefined list)
|
||||||
|
if (
|
||||||
|
data.railpackVersion &&
|
||||||
|
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||||
|
) {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
@@ -186,7 +227,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.2.2"
|
? data.railpackVersion || "0.15.4"
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -403,23 +444,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === BuildType.railpack && (
|
{buildType === BuildType.railpack && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="railpackVersion"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="railpackVersion"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Railpack Version</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Railpack Version</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
placeholder="Railpack Version"
|
{isManualRailpackVersion ? (
|
||||||
{...field}
|
<div className="space-y-2">
|
||||||
value={field.value ?? ""}
|
<Input
|
||||||
/>
|
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ""}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsManualRailpackVersion(false);
|
||||||
|
field.onChange("0.15.4");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use predefined versions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "manual") {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
field.onChange("");
|
||||||
|
} else {
|
||||||
|
field.onChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value ?? "0.15.4"}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Railpack version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">
|
||||||
|
<span className="font-medium">
|
||||||
|
✏️ Manual (Custom Version)
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{RAILPACK_VERSIONS.map((version) => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
{version === "0.15.4" && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 px-1 text-xs"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select a Railpack version or choose manual to enter a
|
||||||
|
custom version.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/railwayapp/railpack/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
View releases
|
||||||
|
</a>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
|||||||
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={deployment.deploymentId}
|
key={deployment.deploymentId}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{index + 1}. {deployment.status}
|
||||||
<StatusTooltip
|
<StatusTooltip
|
||||||
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
{deployment.pid && deployment.status === "running" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Kill Process"
|
||||||
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isKillingProcess}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Kill Process
|
Kill Process
|
||||||
</Button>
|
</Button>
|
||||||
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLog(deployment);
|
setActiveLog(deployment);
|
||||||
}}
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isRollingBack}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
Rollback
|
Rollback
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
|
Hammer,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -22,6 +23,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: redeployPreviewDeployment } =
|
||||||
|
api.previewDeployment.redeploy.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: previewDeployments,
|
data: previewDeployments,
|
||||||
refetch: refetchPreviewDeployments,
|
refetch: refetchPreviewDeployments,
|
||||||
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
|
refetchInterval: (data) =>
|
||||||
|
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Rebuild Preview Deployment"
|
||||||
|
description="Are you sure you want to rebuild this preview deployment?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeployPreviewDeployment({
|
||||||
|
previewDeploymentId:
|
||||||
|
deployment.previewDeploymentId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Preview deployment rebuild started",
|
||||||
|
);
|
||||||
|
refetchPreviewDeployments();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error rebuilding preview deployment",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={status === "running"}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hammer className="size-4" />
|
||||||
|
Rebuild
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent
|
||||||
|
sideOffset={5}
|
||||||
|
className="z-[60]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Rebuild the preview deployment without
|
||||||
|
downloading new code
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
domainId={deployment.domain?.domainId}
|
domainId={deployment.domain?.domainId}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
previewCertificateType: data.previewCertificateType || "none",
|
previewCertificateType: data.previewCertificateType || "none",
|
||||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||||
previewRequireCollaboratorPermissions:
|
previewRequireCollaboratorPermissions:
|
||||||
data.previewRequireCollaboratorPermissions || true,
|
data.previewRequireCollaboratorPermissions ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
Info,
|
Info,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
@@ -13,6 +15,14 @@ import { z } from "zod";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -31,6 +41,12 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -48,6 +64,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { CacheType } from "../domains/handle-domain";
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
import { getTimezoneLabel, TIMEZONES } from "./timezones";
|
||||||
|
|
||||||
export const commonCronExpressions = [
|
export const commonCronExpressions = [
|
||||||
{ label: "Every minute", value: "* * * * *" },
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
@@ -60,30 +77,6 @@ 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"),
|
||||||
@@ -512,25 +505,60 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Popover>
|
||||||
onValueChange={(value) => {
|
<PopoverTrigger asChild>
|
||||||
field.onChange(value);
|
<FormControl>
|
||||||
}}
|
<Button
|
||||||
value={field.value}
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
<FormControl>
|
"w-full justify-between !bg-input",
|
||||||
<SelectTrigger>
|
!field.value && "text-muted-foreground",
|
||||||
<SelectValue placeholder="UTC (default)" />
|
)}
|
||||||
</SelectTrigger>
|
>
|
||||||
</FormControl>
|
{getTimezoneLabel(field.value)}
|
||||||
<SelectContent>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
{commonTimezones.map((tz) => (
|
</Button>
|
||||||
<SelectItem key={tz.value} value={tz.value}>
|
</FormControl>
|
||||||
{tz.label}
|
</PopoverTrigger>
|
||||||
</SelectItem>
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
))}
|
<Command>
|
||||||
</SelectContent>
|
<CommandInput
|
||||||
</Select>
|
placeholder="Search timezone..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
{Object.entries(TIMEZONES).map(
|
||||||
|
([region, zones]) => (
|
||||||
|
<CommandGroup key={region} heading={region}>
|
||||||
|
{zones.map((tz) => (
|
||||||
|
<CommandItem
|
||||||
|
key={tz.value}
|
||||||
|
value={`${region} ${tz.label} ${tz.value}`}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(tz.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tz.value}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
field.value === tz.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Optional: Choose a timezone for the schedule execution time
|
Optional: Choose a timezone for the schedule execution time
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// Complete list of IANA timezones grouped by region
|
||||||
|
export const TIMEZONES: Record<
|
||||||
|
string,
|
||||||
|
Array<{ label: string; value: string }>
|
||||||
|
> = {
|
||||||
|
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
|
||||||
|
Africa: [
|
||||||
|
{ label: "Abidjan", value: "Africa/Abidjan" },
|
||||||
|
{ label: "Accra", value: "Africa/Accra" },
|
||||||
|
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
|
||||||
|
{ label: "Algiers", value: "Africa/Algiers" },
|
||||||
|
{ label: "Asmara", value: "Africa/Asmara" },
|
||||||
|
{ label: "Bamako", value: "Africa/Bamako" },
|
||||||
|
{ label: "Bangui", value: "Africa/Bangui" },
|
||||||
|
{ label: "Banjul", value: "Africa/Banjul" },
|
||||||
|
{ label: "Bissau", value: "Africa/Bissau" },
|
||||||
|
{ label: "Blantyre", value: "Africa/Blantyre" },
|
||||||
|
{ label: "Brazzaville", value: "Africa/Brazzaville" },
|
||||||
|
{ label: "Bujumbura", value: "Africa/Bujumbura" },
|
||||||
|
{ label: "Cairo", value: "Africa/Cairo" },
|
||||||
|
{ label: "Casablanca", value: "Africa/Casablanca" },
|
||||||
|
{ label: "Ceuta", value: "Africa/Ceuta" },
|
||||||
|
{ label: "Conakry", value: "Africa/Conakry" },
|
||||||
|
{ label: "Dakar", value: "Africa/Dakar" },
|
||||||
|
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
|
||||||
|
{ label: "Djibouti", value: "Africa/Djibouti" },
|
||||||
|
{ label: "Douala", value: "Africa/Douala" },
|
||||||
|
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
|
||||||
|
{ label: "Freetown", value: "Africa/Freetown" },
|
||||||
|
{ label: "Gaborone", value: "Africa/Gaborone" },
|
||||||
|
{ label: "Harare", value: "Africa/Harare" },
|
||||||
|
{ label: "Johannesburg", value: "Africa/Johannesburg" },
|
||||||
|
{ label: "Juba", value: "Africa/Juba" },
|
||||||
|
{ label: "Kampala", value: "Africa/Kampala" },
|
||||||
|
{ label: "Khartoum", value: "Africa/Khartoum" },
|
||||||
|
{ label: "Kigali", value: "Africa/Kigali" },
|
||||||
|
{ label: "Kinshasa", value: "Africa/Kinshasa" },
|
||||||
|
{ label: "Lagos", value: "Africa/Lagos" },
|
||||||
|
{ label: "Libreville", value: "Africa/Libreville" },
|
||||||
|
{ label: "Lome", value: "Africa/Lome" },
|
||||||
|
{ label: "Luanda", value: "Africa/Luanda" },
|
||||||
|
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
|
||||||
|
{ label: "Lusaka", value: "Africa/Lusaka" },
|
||||||
|
{ label: "Malabo", value: "Africa/Malabo" },
|
||||||
|
{ label: "Maputo", value: "Africa/Maputo" },
|
||||||
|
{ label: "Maseru", value: "Africa/Maseru" },
|
||||||
|
{ label: "Mbabane", value: "Africa/Mbabane" },
|
||||||
|
{ label: "Mogadishu", value: "Africa/Mogadishu" },
|
||||||
|
{ label: "Monrovia", value: "Africa/Monrovia" },
|
||||||
|
{ label: "Nairobi", value: "Africa/Nairobi" },
|
||||||
|
{ label: "Ndjamena", value: "Africa/Ndjamena" },
|
||||||
|
{ label: "Niamey", value: "Africa/Niamey" },
|
||||||
|
{ label: "Nouakchott", value: "Africa/Nouakchott" },
|
||||||
|
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
|
||||||
|
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
|
||||||
|
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
|
||||||
|
{ label: "Tripoli", value: "Africa/Tripoli" },
|
||||||
|
{ label: "Tunis", value: "Africa/Tunis" },
|
||||||
|
{ label: "Windhoek", value: "Africa/Windhoek" },
|
||||||
|
],
|
||||||
|
America: [
|
||||||
|
{ label: "Adak", value: "America/Adak" },
|
||||||
|
{ label: "Anchorage", value: "America/Anchorage" },
|
||||||
|
{ label: "Anguilla", value: "America/Anguilla" },
|
||||||
|
{ label: "Antigua", value: "America/Antigua" },
|
||||||
|
{ label: "Araguaina", value: "America/Araguaina" },
|
||||||
|
{
|
||||||
|
label: "Argentina/Buenos Aires",
|
||||||
|
value: "America/Argentina/Buenos_Aires",
|
||||||
|
},
|
||||||
|
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
|
||||||
|
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
|
||||||
|
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
|
||||||
|
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
|
||||||
|
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
|
||||||
|
{
|
||||||
|
label: "Argentina/Rio Gallegos",
|
||||||
|
value: "America/Argentina/Rio_Gallegos",
|
||||||
|
},
|
||||||
|
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
|
||||||
|
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
|
||||||
|
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
|
||||||
|
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
|
||||||
|
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
|
||||||
|
{ label: "Aruba", value: "America/Aruba" },
|
||||||
|
{ label: "Asuncion", value: "America/Asuncion" },
|
||||||
|
{ label: "Atikokan", value: "America/Atikokan" },
|
||||||
|
{ label: "Bahia", value: "America/Bahia" },
|
||||||
|
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
|
||||||
|
{ label: "Barbados", value: "America/Barbados" },
|
||||||
|
{ label: "Belem", value: "America/Belem" },
|
||||||
|
{ label: "Belize", value: "America/Belize" },
|
||||||
|
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
|
||||||
|
{ label: "Boa Vista", value: "America/Boa_Vista" },
|
||||||
|
{ label: "Bogota", value: "America/Bogota" },
|
||||||
|
{ label: "Boise", value: "America/Boise" },
|
||||||
|
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
|
||||||
|
{ label: "Campo Grande", value: "America/Campo_Grande" },
|
||||||
|
{ label: "Cancun", value: "America/Cancun" },
|
||||||
|
{ label: "Caracas", value: "America/Caracas" },
|
||||||
|
{ label: "Cayenne", value: "America/Cayenne" },
|
||||||
|
{ label: "Cayman", value: "America/Cayman" },
|
||||||
|
{ label: "Chicago (Central Time)", value: "America/Chicago" },
|
||||||
|
{ label: "Chihuahua", value: "America/Chihuahua" },
|
||||||
|
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
|
||||||
|
{ label: "Costa Rica", value: "America/Costa_Rica" },
|
||||||
|
{ label: "Creston", value: "America/Creston" },
|
||||||
|
{ label: "Cuiaba", value: "America/Cuiaba" },
|
||||||
|
{ label: "Curacao", value: "America/Curacao" },
|
||||||
|
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
|
||||||
|
{ label: "Dawson", value: "America/Dawson" },
|
||||||
|
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
|
||||||
|
{ label: "Denver (Mountain Time)", value: "America/Denver" },
|
||||||
|
{ label: "Detroit", value: "America/Detroit" },
|
||||||
|
{ label: "Dominica", value: "America/Dominica" },
|
||||||
|
{ label: "Edmonton", value: "America/Edmonton" },
|
||||||
|
{ label: "Eirunepe", value: "America/Eirunepe" },
|
||||||
|
{ label: "El Salvador", value: "America/El_Salvador" },
|
||||||
|
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
|
||||||
|
{ label: "Fortaleza", value: "America/Fortaleza" },
|
||||||
|
{ label: "Glace Bay", value: "America/Glace_Bay" },
|
||||||
|
{ label: "Goose Bay", value: "America/Goose_Bay" },
|
||||||
|
{ label: "Grand Turk", value: "America/Grand_Turk" },
|
||||||
|
{ label: "Grenada", value: "America/Grenada" },
|
||||||
|
{ label: "Guadeloupe", value: "America/Guadeloupe" },
|
||||||
|
{ label: "Guatemala", value: "America/Guatemala" },
|
||||||
|
{ label: "Guayaquil", value: "America/Guayaquil" },
|
||||||
|
{ label: "Guyana", value: "America/Guyana" },
|
||||||
|
{ label: "Halifax", value: "America/Halifax" },
|
||||||
|
{ label: "Havana", value: "America/Havana" },
|
||||||
|
{ label: "Hermosillo", value: "America/Hermosillo" },
|
||||||
|
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
|
||||||
|
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
|
||||||
|
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
|
||||||
|
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
|
||||||
|
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
|
||||||
|
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
|
||||||
|
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
|
||||||
|
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
|
||||||
|
{ label: "Inuvik", value: "America/Inuvik" },
|
||||||
|
{ label: "Iqaluit", value: "America/Iqaluit" },
|
||||||
|
{ label: "Jamaica", value: "America/Jamaica" },
|
||||||
|
{ label: "Juneau", value: "America/Juneau" },
|
||||||
|
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
|
||||||
|
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
|
||||||
|
{ label: "Kralendijk", value: "America/Kralendijk" },
|
||||||
|
{ label: "La Paz", value: "America/La_Paz" },
|
||||||
|
{ label: "Lima", value: "America/Lima" },
|
||||||
|
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||||
|
{ label: "Lower Princes", value: "America/Lower_Princes" },
|
||||||
|
{ label: "Maceio", value: "America/Maceio" },
|
||||||
|
{ label: "Managua", value: "America/Managua" },
|
||||||
|
{ label: "Manaus", value: "America/Manaus" },
|
||||||
|
{ label: "Marigot", value: "America/Marigot" },
|
||||||
|
{ label: "Martinique", value: "America/Martinique" },
|
||||||
|
{ label: "Matamoros", value: "America/Matamoros" },
|
||||||
|
{ label: "Mazatlan", value: "America/Mazatlan" },
|
||||||
|
{ label: "Menominee", value: "America/Menominee" },
|
||||||
|
{ label: "Merida", value: "America/Merida" },
|
||||||
|
{ label: "Metlakatla", value: "America/Metlakatla" },
|
||||||
|
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
|
||||||
|
{ label: "Miquelon", value: "America/Miquelon" },
|
||||||
|
{ label: "Moncton", value: "America/Moncton" },
|
||||||
|
{ label: "Monterrey", value: "America/Monterrey" },
|
||||||
|
{ label: "Montevideo", value: "America/Montevideo" },
|
||||||
|
{ label: "Montserrat", value: "America/Montserrat" },
|
||||||
|
{ label: "Nassau", value: "America/Nassau" },
|
||||||
|
{ label: "New York (Eastern Time)", value: "America/New_York" },
|
||||||
|
{ label: "Nome", value: "America/Nome" },
|
||||||
|
{ label: "Noronha", value: "America/Noronha" },
|
||||||
|
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
|
||||||
|
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
|
||||||
|
{
|
||||||
|
label: "North Dakota/New Salem",
|
||||||
|
value: "America/North_Dakota/New_Salem",
|
||||||
|
},
|
||||||
|
{ label: "Nuuk", value: "America/Nuuk" },
|
||||||
|
{ label: "Ojinaga", value: "America/Ojinaga" },
|
||||||
|
{ label: "Panama", value: "America/Panama" },
|
||||||
|
{ label: "Paramaribo", value: "America/Paramaribo" },
|
||||||
|
{ label: "Phoenix", value: "America/Phoenix" },
|
||||||
|
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
|
||||||
|
{ label: "Port of Spain", value: "America/Port_of_Spain" },
|
||||||
|
{ label: "Porto Velho", value: "America/Porto_Velho" },
|
||||||
|
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
|
||||||
|
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
|
||||||
|
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
|
||||||
|
{ label: "Recife", value: "America/Recife" },
|
||||||
|
{ label: "Regina", value: "America/Regina" },
|
||||||
|
{ label: "Resolute", value: "America/Resolute" },
|
||||||
|
{ label: "Rio Branco", value: "America/Rio_Branco" },
|
||||||
|
{ label: "Santarem", value: "America/Santarem" },
|
||||||
|
{ label: "Santiago", value: "America/Santiago" },
|
||||||
|
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
|
||||||
|
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||||
|
{ label: "Scoresbysund", value: "America/Scoresbysund" },
|
||||||
|
{ label: "Sitka", value: "America/Sitka" },
|
||||||
|
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
|
||||||
|
{ label: "St Johns", value: "America/St_Johns" },
|
||||||
|
{ label: "St Kitts", value: "America/St_Kitts" },
|
||||||
|
{ label: "St Lucia", value: "America/St_Lucia" },
|
||||||
|
{ label: "St Thomas", value: "America/St_Thomas" },
|
||||||
|
{ label: "St Vincent", value: "America/St_Vincent" },
|
||||||
|
{ label: "Swift Current", value: "America/Swift_Current" },
|
||||||
|
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
|
||||||
|
{ label: "Thule", value: "America/Thule" },
|
||||||
|
{ label: "Tijuana", value: "America/Tijuana" },
|
||||||
|
{ label: "Toronto", value: "America/Toronto" },
|
||||||
|
{ label: "Tortola", value: "America/Tortola" },
|
||||||
|
{ label: "Vancouver", value: "America/Vancouver" },
|
||||||
|
{ label: "Whitehorse", value: "America/Whitehorse" },
|
||||||
|
{ label: "Winnipeg", value: "America/Winnipeg" },
|
||||||
|
{ label: "Yakutat", value: "America/Yakutat" },
|
||||||
|
],
|
||||||
|
Antarctica: [
|
||||||
|
{ label: "Casey", value: "Antarctica/Casey" },
|
||||||
|
{ label: "Davis", value: "Antarctica/Davis" },
|
||||||
|
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
|
||||||
|
{ label: "Macquarie", value: "Antarctica/Macquarie" },
|
||||||
|
{ label: "Mawson", value: "Antarctica/Mawson" },
|
||||||
|
{ label: "McMurdo", value: "Antarctica/McMurdo" },
|
||||||
|
{ label: "Palmer", value: "Antarctica/Palmer" },
|
||||||
|
{ label: "Rothera", value: "Antarctica/Rothera" },
|
||||||
|
{ label: "Syowa", value: "Antarctica/Syowa" },
|
||||||
|
{ label: "Troll", value: "Antarctica/Troll" },
|
||||||
|
{ label: "Vostok", value: "Antarctica/Vostok" },
|
||||||
|
],
|
||||||
|
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
|
||||||
|
Asia: [
|
||||||
|
{ label: "Aden", value: "Asia/Aden" },
|
||||||
|
{ label: "Almaty", value: "Asia/Almaty" },
|
||||||
|
{ label: "Amman", value: "Asia/Amman" },
|
||||||
|
{ label: "Anadyr", value: "Asia/Anadyr" },
|
||||||
|
{ label: "Aqtau", value: "Asia/Aqtau" },
|
||||||
|
{ label: "Aqtobe", value: "Asia/Aqtobe" },
|
||||||
|
{ label: "Ashgabat", value: "Asia/Ashgabat" },
|
||||||
|
{ label: "Atyrau", value: "Asia/Atyrau" },
|
||||||
|
{ label: "Baghdad", value: "Asia/Baghdad" },
|
||||||
|
{ label: "Bahrain", value: "Asia/Bahrain" },
|
||||||
|
{ label: "Baku", value: "Asia/Baku" },
|
||||||
|
{ label: "Bangkok", value: "Asia/Bangkok" },
|
||||||
|
{ label: "Barnaul", value: "Asia/Barnaul" },
|
||||||
|
{ label: "Beirut", value: "Asia/Beirut" },
|
||||||
|
{ label: "Bishkek", value: "Asia/Bishkek" },
|
||||||
|
{ label: "Brunei", value: "Asia/Brunei" },
|
||||||
|
{ label: "Chita", value: "Asia/Chita" },
|
||||||
|
{ label: "Choibalsan", value: "Asia/Choibalsan" },
|
||||||
|
{ label: "Colombo", value: "Asia/Colombo" },
|
||||||
|
{ label: "Damascus", value: "Asia/Damascus" },
|
||||||
|
{ label: "Dhaka", value: "Asia/Dhaka" },
|
||||||
|
{ label: "Dili", value: "Asia/Dili" },
|
||||||
|
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||||
|
{ label: "Dushanbe", value: "Asia/Dushanbe" },
|
||||||
|
{ label: "Famagusta", value: "Asia/Famagusta" },
|
||||||
|
{ label: "Gaza", value: "Asia/Gaza" },
|
||||||
|
{ label: "Hebron", value: "Asia/Hebron" },
|
||||||
|
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
|
||||||
|
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
|
||||||
|
{ label: "Hovd", value: "Asia/Hovd" },
|
||||||
|
{ label: "Irkutsk", value: "Asia/Irkutsk" },
|
||||||
|
{ label: "Jakarta", value: "Asia/Jakarta" },
|
||||||
|
{ label: "Jayapura", value: "Asia/Jayapura" },
|
||||||
|
{ label: "Jerusalem", value: "Asia/Jerusalem" },
|
||||||
|
{ label: "Kabul", value: "Asia/Kabul" },
|
||||||
|
{ label: "Kamchatka", value: "Asia/Kamchatka" },
|
||||||
|
{ label: "Karachi", value: "Asia/Karachi" },
|
||||||
|
{ label: "Kathmandu", value: "Asia/Kathmandu" },
|
||||||
|
{ label: "Khandyga", value: "Asia/Khandyga" },
|
||||||
|
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||||
|
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
|
||||||
|
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
|
||||||
|
{ label: "Kuching", value: "Asia/Kuching" },
|
||||||
|
{ label: "Kuwait", value: "Asia/Kuwait" },
|
||||||
|
{ label: "Macau", value: "Asia/Macau" },
|
||||||
|
{ label: "Magadan", value: "Asia/Magadan" },
|
||||||
|
{ label: "Makassar", value: "Asia/Makassar" },
|
||||||
|
{ label: "Manila", value: "Asia/Manila" },
|
||||||
|
{ label: "Muscat", value: "Asia/Muscat" },
|
||||||
|
{ label: "Nicosia", value: "Asia/Nicosia" },
|
||||||
|
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
|
||||||
|
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
|
||||||
|
{ label: "Omsk", value: "Asia/Omsk" },
|
||||||
|
{ label: "Oral", value: "Asia/Oral" },
|
||||||
|
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
|
||||||
|
{ label: "Pontianak", value: "Asia/Pontianak" },
|
||||||
|
{ label: "Pyongyang", value: "Asia/Pyongyang" },
|
||||||
|
{ label: "Qatar", value: "Asia/Qatar" },
|
||||||
|
{ label: "Qostanay", value: "Asia/Qostanay" },
|
||||||
|
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
|
||||||
|
{ label: "Riyadh", value: "Asia/Riyadh" },
|
||||||
|
{ label: "Sakhalin", value: "Asia/Sakhalin" },
|
||||||
|
{ label: "Samarkand", value: "Asia/Samarkand" },
|
||||||
|
{ label: "Seoul", value: "Asia/Seoul" },
|
||||||
|
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||||
|
{ label: "Singapore", value: "Asia/Singapore" },
|
||||||
|
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
|
||||||
|
{ label: "Taipei", value: "Asia/Taipei" },
|
||||||
|
{ label: "Tashkent", value: "Asia/Tashkent" },
|
||||||
|
{ label: "Tbilisi", value: "Asia/Tbilisi" },
|
||||||
|
{ label: "Tehran", value: "Asia/Tehran" },
|
||||||
|
{ label: "Thimphu", value: "Asia/Thimphu" },
|
||||||
|
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||||
|
{ label: "Tomsk", value: "Asia/Tomsk" },
|
||||||
|
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
|
||||||
|
{ label: "Urumqi", value: "Asia/Urumqi" },
|
||||||
|
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
|
||||||
|
{ label: "Vientiane", value: "Asia/Vientiane" },
|
||||||
|
{ label: "Vladivostok", value: "Asia/Vladivostok" },
|
||||||
|
{ label: "Yakutsk", value: "Asia/Yakutsk" },
|
||||||
|
{ label: "Yangon", value: "Asia/Yangon" },
|
||||||
|
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
|
||||||
|
{ label: "Yerevan", value: "Asia/Yerevan" },
|
||||||
|
],
|
||||||
|
Atlantic: [
|
||||||
|
{ label: "Azores", value: "Atlantic/Azores" },
|
||||||
|
{ label: "Bermuda", value: "Atlantic/Bermuda" },
|
||||||
|
{ label: "Canary", value: "Atlantic/Canary" },
|
||||||
|
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
|
||||||
|
{ label: "Faroe", value: "Atlantic/Faroe" },
|
||||||
|
{ label: "Madeira", value: "Atlantic/Madeira" },
|
||||||
|
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
|
||||||
|
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
|
||||||
|
{ label: "St Helena", value: "Atlantic/St_Helena" },
|
||||||
|
{ label: "Stanley", value: "Atlantic/Stanley" },
|
||||||
|
],
|
||||||
|
Australia: [
|
||||||
|
{ label: "Adelaide", value: "Australia/Adelaide" },
|
||||||
|
{ label: "Brisbane", value: "Australia/Brisbane" },
|
||||||
|
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
|
||||||
|
{ label: "Darwin", value: "Australia/Darwin" },
|
||||||
|
{ label: "Eucla", value: "Australia/Eucla" },
|
||||||
|
{ label: "Hobart", value: "Australia/Hobart" },
|
||||||
|
{ label: "Lindeman", value: "Australia/Lindeman" },
|
||||||
|
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
|
||||||
|
{ label: "Melbourne", value: "Australia/Melbourne" },
|
||||||
|
{ label: "Perth", value: "Australia/Perth" },
|
||||||
|
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
|
||||||
|
],
|
||||||
|
Europe: [
|
||||||
|
{ label: "Amsterdam", value: "Europe/Amsterdam" },
|
||||||
|
{ label: "Andorra", value: "Europe/Andorra" },
|
||||||
|
{ label: "Astrakhan", value: "Europe/Astrakhan" },
|
||||||
|
{ label: "Athens", value: "Europe/Athens" },
|
||||||
|
{ label: "Belgrade", value: "Europe/Belgrade" },
|
||||||
|
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||||
|
{ label: "Bratislava", value: "Europe/Bratislava" },
|
||||||
|
{ label: "Brussels", value: "Europe/Brussels" },
|
||||||
|
{ label: "Bucharest", value: "Europe/Bucharest" },
|
||||||
|
{ label: "Budapest", value: "Europe/Budapest" },
|
||||||
|
{ label: "Busingen", value: "Europe/Busingen" },
|
||||||
|
{ label: "Chisinau", value: "Europe/Chisinau" },
|
||||||
|
{ label: "Copenhagen", value: "Europe/Copenhagen" },
|
||||||
|
{ label: "Dublin", value: "Europe/Dublin" },
|
||||||
|
{ label: "Gibraltar", value: "Europe/Gibraltar" },
|
||||||
|
{ label: "Guernsey", value: "Europe/Guernsey" },
|
||||||
|
{ label: "Helsinki", value: "Europe/Helsinki" },
|
||||||
|
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
|
||||||
|
{ label: "Istanbul", value: "Europe/Istanbul" },
|
||||||
|
{ label: "Jersey", value: "Europe/Jersey" },
|
||||||
|
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
|
||||||
|
{ label: "Kirov", value: "Europe/Kirov" },
|
||||||
|
{ label: "Kyiv", value: "Europe/Kyiv" },
|
||||||
|
{ label: "Lisbon", value: "Europe/Lisbon" },
|
||||||
|
{ label: "Ljubljana", value: "Europe/Ljubljana" },
|
||||||
|
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
|
||||||
|
{ label: "Luxembourg", value: "Europe/Luxembourg" },
|
||||||
|
{ label: "Madrid", value: "Europe/Madrid" },
|
||||||
|
{ label: "Malta", value: "Europe/Malta" },
|
||||||
|
{ label: "Mariehamn", value: "Europe/Mariehamn" },
|
||||||
|
{ label: "Minsk", value: "Europe/Minsk" },
|
||||||
|
{ label: "Monaco", value: "Europe/Monaco" },
|
||||||
|
{ label: "Moscow", value: "Europe/Moscow" },
|
||||||
|
{ label: "Oslo", value: "Europe/Oslo" },
|
||||||
|
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
|
||||||
|
{ label: "Podgorica", value: "Europe/Podgorica" },
|
||||||
|
{ label: "Prague", value: "Europe/Prague" },
|
||||||
|
{ label: "Riga", value: "Europe/Riga" },
|
||||||
|
{ label: "Rome", value: "Europe/Rome" },
|
||||||
|
{ label: "Samara", value: "Europe/Samara" },
|
||||||
|
{ label: "San Marino", value: "Europe/San_Marino" },
|
||||||
|
{ label: "Sarajevo", value: "Europe/Sarajevo" },
|
||||||
|
{ label: "Saratov", value: "Europe/Saratov" },
|
||||||
|
{ label: "Simferopol", value: "Europe/Simferopol" },
|
||||||
|
{ label: "Skopje", value: "Europe/Skopje" },
|
||||||
|
{ label: "Sofia", value: "Europe/Sofia" },
|
||||||
|
{ label: "Stockholm", value: "Europe/Stockholm" },
|
||||||
|
{ label: "Tallinn", value: "Europe/Tallinn" },
|
||||||
|
{ label: "Tirane", value: "Europe/Tirane" },
|
||||||
|
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
|
||||||
|
{ label: "Vaduz", value: "Europe/Vaduz" },
|
||||||
|
{ label: "Vatican", value: "Europe/Vatican" },
|
||||||
|
{ label: "Vienna", value: "Europe/Vienna" },
|
||||||
|
{ label: "Vilnius", value: "Europe/Vilnius" },
|
||||||
|
{ label: "Volgograd", value: "Europe/Volgograd" },
|
||||||
|
{ label: "Warsaw", value: "Europe/Warsaw" },
|
||||||
|
{ label: "Zagreb", value: "Europe/Zagreb" },
|
||||||
|
{ label: "Zurich", value: "Europe/Zurich" },
|
||||||
|
],
|
||||||
|
Indian: [
|
||||||
|
{ label: "Antananarivo", value: "Indian/Antananarivo" },
|
||||||
|
{ label: "Chagos", value: "Indian/Chagos" },
|
||||||
|
{ label: "Christmas", value: "Indian/Christmas" },
|
||||||
|
{ label: "Cocos", value: "Indian/Cocos" },
|
||||||
|
{ label: "Comoro", value: "Indian/Comoro" },
|
||||||
|
{ label: "Kerguelen", value: "Indian/Kerguelen" },
|
||||||
|
{ label: "Mahe", value: "Indian/Mahe" },
|
||||||
|
{ label: "Maldives", value: "Indian/Maldives" },
|
||||||
|
{ label: "Mauritius", value: "Indian/Mauritius" },
|
||||||
|
{ label: "Mayotte", value: "Indian/Mayotte" },
|
||||||
|
{ label: "Reunion", value: "Indian/Reunion" },
|
||||||
|
],
|
||||||
|
Pacific: [
|
||||||
|
{ label: "Apia", value: "Pacific/Apia" },
|
||||||
|
{ label: "Auckland", value: "Pacific/Auckland" },
|
||||||
|
{ label: "Bougainville", value: "Pacific/Bougainville" },
|
||||||
|
{ label: "Chatham", value: "Pacific/Chatham" },
|
||||||
|
{ label: "Chuuk", value: "Pacific/Chuuk" },
|
||||||
|
{ label: "Easter", value: "Pacific/Easter" },
|
||||||
|
{ label: "Efate", value: "Pacific/Efate" },
|
||||||
|
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
|
||||||
|
{ label: "Fiji", value: "Pacific/Fiji" },
|
||||||
|
{ label: "Funafuti", value: "Pacific/Funafuti" },
|
||||||
|
{ label: "Galapagos", value: "Pacific/Galapagos" },
|
||||||
|
{ label: "Gambier", value: "Pacific/Gambier" },
|
||||||
|
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
|
||||||
|
{ label: "Guam", value: "Pacific/Guam" },
|
||||||
|
{ label: "Honolulu", value: "Pacific/Honolulu" },
|
||||||
|
{ label: "Kanton", value: "Pacific/Kanton" },
|
||||||
|
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
|
||||||
|
{ label: "Kosrae", value: "Pacific/Kosrae" },
|
||||||
|
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
|
||||||
|
{ label: "Majuro", value: "Pacific/Majuro" },
|
||||||
|
{ label: "Marquesas", value: "Pacific/Marquesas" },
|
||||||
|
{ label: "Midway", value: "Pacific/Midway" },
|
||||||
|
{ label: "Nauru", value: "Pacific/Nauru" },
|
||||||
|
{ label: "Niue", value: "Pacific/Niue" },
|
||||||
|
{ label: "Norfolk", value: "Pacific/Norfolk" },
|
||||||
|
{ label: "Noumea", value: "Pacific/Noumea" },
|
||||||
|
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
|
||||||
|
{ label: "Palau", value: "Pacific/Palau" },
|
||||||
|
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
|
||||||
|
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
|
||||||
|
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
|
||||||
|
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
|
||||||
|
{ label: "Saipan", value: "Pacific/Saipan" },
|
||||||
|
{ label: "Tahiti", value: "Pacific/Tahiti" },
|
||||||
|
{ label: "Tarawa", value: "Pacific/Tarawa" },
|
||||||
|
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
|
||||||
|
{ label: "Wake", value: "Pacific/Wake" },
|
||||||
|
{ label: "Wallis", value: "Pacific/Wallis" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get display label for a timezone value
|
||||||
|
export function getTimezoneLabel(value: string | undefined): string {
|
||||||
|
if (!value) return "UTC (default)";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } 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";
|
||||||
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const gitlabId = form.watch("gitlabId");
|
const gitlabId = form.watch("gitlabId");
|
||||||
|
|
||||||
|
const gitlabUrl = useMemo(() => {
|
||||||
|
const url = gitlabProviders?.find(
|
||||||
|
(provider) => provider.gitlabId === gitlabId,
|
||||||
|
)?.gitlabUrl;
|
||||||
|
|
||||||
|
const gitlabUrl = url?.replace(/\/$/, "");
|
||||||
|
|
||||||
|
return gitlabUrl || "https://gitlab.com";
|
||||||
|
}, [gitlabId, gitlabProviders]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: repositories,
|
data: repositories,
|
||||||
isLoading: isLoadingRepositories,
|
isLoading: isLoadingRepositories,
|
||||||
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
|
|||||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
|
||||||
|
/⚠|⚠️/i.test(lowerMessage)
|
||||||
) {
|
) {
|
||||||
return LOG_STYLES.warning;
|
return LOG_STYLES.warning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ export const ShowProjects = () => {
|
|||||||
Create and manage your projects
|
Create and manage your projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canCreateProjects) && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<HandleProject />
|
<HandleProject />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CreditCard, FileText } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ShowInvoices } from "./show-invoices";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ShowBillingInvoices = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
|
Billing
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ShowInvoices />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
|||||||
if (count <= 1) return 4.5;
|
if (count <= 1) return 4.5;
|
||||||
return count * 3.5;
|
return count * 3.5;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const ShowBilling = () => {
|
export const ShowBilling = () => {
|
||||||
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||||
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="bg-sidebar 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>
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
Billing
|
Billing
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage your subscription</CardDescription>
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="monthly"
|
defaultValue="monthly"
|
||||||
value={isAnnual ? "annual" : "monthly"}
|
value={isAnnual ? "annual" : "monthly"}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return "-";
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
||||||
|
const statusConfig: Record<
|
||||||
|
Stripe.Invoice.Status,
|
||||||
|
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||||
|
> = {
|
||||||
|
paid: { label: "Paid", variant: "default" },
|
||||||
|
open: { label: "Open", variant: "secondary" },
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
void: { label: "Void", variant: "destructive" },
|
||||||
|
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return <Badge variant="secondary">Unknown</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status] || {
|
||||||
|
label: status,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowInvoices = () => {
|
||||||
|
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[20vh]">
|
||||||
|
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
||||||
|
Loading invoices...
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : invoices && invoices.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Due Date</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<TableRow key={invoice.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{invoice.number || invoice.id.slice(0, 12)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatAmount(invoice.amountDue, invoice.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{invoice.hostedInvoiceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
invoice.hostedInvoiceUrl || "",
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.invoicePdf && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(invoice.invoicePdf || "", "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
||||||
|
<FileText className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No invoices found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your invoices will appear here once you have a subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -42,9 +42,7 @@ 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
|
registryUrl: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -75,6 +73,7 @@ const AddRegistrySchema = z.object({
|
|||||||
),
|
),
|
||||||
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>;
|
||||||
@@ -101,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: "",
|
||||||
@@ -116,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");
|
||||||
@@ -138,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({
|
||||||
@@ -146,13 +172,13 @@ 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 || "",
|
||||||
@@ -160,7 +186,15 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
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");
|
||||||
@@ -198,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>
|
||||||
)}
|
)}
|
||||||
@@ -253,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"
|
||||||
@@ -360,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>
|
||||||
@@ -387,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,
|
||||||
@@ -396,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) {
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2, User } from "lucide-react";
|
import { Loader2, Palette, User } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, 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";
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Configure2FA } from "./configure-2fa";
|
import { Configure2FA } from "./configure-2fa";
|
||||||
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
|
|||||||
} = api.user.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const availableAvatars = useMemo(() => {
|
const availableAvatars = useMemo(() => {
|
||||||
if (gravatarHash === null) return randomImages;
|
if (gravatarHash === null) return randomImages;
|
||||||
@@ -274,16 +276,8 @@ export const ProfileForm = () => {
|
|||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
defaultValue={
|
defaultValue={getAvatarType(field.value)}
|
||||||
field.value?.startsWith("data:")
|
value={getAvatarType(field.value)}
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
field.value?.startsWith("data:")
|
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||||
>
|
>
|
||||||
<FormItem key="no-avatar">
|
<FormItem key="no-avatar">
|
||||||
@@ -370,6 +364,40 @@ export const ProfileForm = () => {
|
|||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem key="color-avatar">
|
||||||
|
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="color"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div
|
||||||
|
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSolidColorAvatar(
|
||||||
|
field.value,
|
||||||
|
)
|
||||||
|
? field.value
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
colorInputRef.current?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isSolidColorAvatar(field.value) && (
|
||||||
|
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
{availableAvatars.map((image) => (
|
{availableAvatars.map((image) => (
|
||||||
<FormItem key={image}>
|
<FormItem key={image}>
|
||||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Activity } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
||||||
enabled: !serverId,
|
undefined,
|
||||||
});
|
{
|
||||||
|
enabled: !serverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const enabled = serverId
|
const enabled = serverId
|
||||||
? server?.enableDockerCleanup
|
? server?.enableDockerCleanup
|
||||||
: data?.user.enableDockerCleanup;
|
: data?.enableDockerCleanup;
|
||||||
|
|
||||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
try {
|
try {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
enableDockerCleanup: checked,
|
enableDockerCleanup: checked,
|
||||||
serverId: serverId,
|
...(serverId && { serverId }),
|
||||||
|
} as {
|
||||||
|
enableDockerCleanup: boolean;
|
||||||
|
serverId?: string;
|
||||||
});
|
});
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await refetchServer();
|
await refetchServer();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { Pencil, PlusIcon } 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>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
|||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||||
const { data } = serverId
|
const { data: serverData } = serverId
|
||||||
? api.server.one.useQuery(
|
? api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: api.user.getServerMetrics.useQuery();
|
: { data: null };
|
||||||
|
|
||||||
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||||
|
enabled: !serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = serverId ? serverData : webServerSettings;
|
||||||
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -36,9 +35,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 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
<DropdownMenuItem
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Setup Server
|
Setup Server <Settings className="size-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</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,16 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
|
import {
|
||||||
|
Clock,
|
||||||
|
Key,
|
||||||
|
KeyIcon,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Network,
|
||||||
|
ServerIcon,
|
||||||
|
Terminal,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} 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";
|
||||||
@@ -18,20 +29,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
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 +65,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 +120,320 @@ 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 flex-wrap">
|
||||||
<Button
|
<div className="flex items-center gap-2 w-full">
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
className="h-8 w-8 p-0"
|
<TooltipTrigger asChild>
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
Open menu
|
|
||||||
</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
|
<SetupServer
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-xs"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
Setup Server
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure and initialize your
|
||||||
|
server with Docker, Traefik, and
|
||||||
|
other essential services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HandleServers
|
<TooltipProvider>
|
||||||
serverId={server.serverId}
|
{server.sshKeyId && (
|
||||||
/>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
{server.sshKeyId &&
|
<div>
|
||||||
!isBuildServer && (
|
<TerminalModal
|
||||||
<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
|
<HandleServers
|
||||||
? "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>
|
||||||
serverId={server.serverId}
|
</TooltipTrigger>
|
||||||
/>
|
<TooltipContent>
|
||||||
{isCloud && (
|
<p>Edit Server</p>
|
||||||
<ShowMonitoringModal
|
</TooltipContent>
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
</Tooltip>
|
||||||
token={
|
|
||||||
server?.metricsConfig
|
{server.sshKeyId && !isBuildServer && (
|
||||||
?.server?.token
|
<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 />
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
|||||||
|
|
||||||
export const WebDomain = () => {
|
export const WebDomain = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data, refetch } = api.user.get.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.settings.assignDomainServer.useMutation();
|
api.settings.assignDomainServer.useMutation();
|
||||||
|
|
||||||
@@ -82,15 +82,15 @@ export const WebDomain = () => {
|
|||||||
});
|
});
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domain = form.watch("domain") || "";
|
const domain = form.watch("domain") || "";
|
||||||
const host = data?.user?.host || "";
|
const host = data?.host || "";
|
||||||
const hasChanged = domain !== host;
|
const hasChanged = domain !== host;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
domain: data?.user?.host || "",
|
domain: data?.host || "",
|
||||||
certificateType: data?.user?.certificateType,
|
certificateType: data?.certificateType || "none",
|
||||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
letsEncryptEmail: data?.letsEncryptEmail || "",
|
||||||
https: data?.user?.https || false,
|
https: data?.https || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server";
|
|||||||
|
|
||||||
export const WebServer = () => {
|
export const WebServer = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data } = api.user.get.useQuery();
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export const WebServer = () => {
|
|||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Server IP: {data?.user.serverIp}
|
Server IP: {webServerSettings?.serverIp}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -46,15 +46,15 @@ interface Props {
|
|||||||
export const UpdateServerIp = ({ children }: Props) => {
|
export const UpdateServerIp = ({ children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { data } = api.user.get.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { data: ip } = api.server.publicIp.useQuery();
|
const { data: ip } = api.server.publicIp.useQuery();
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.user.update.useMutation();
|
api.settings.updateServerIp.useMutation();
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
serverIp: data?.user.serverIp || "",
|
serverIp: data?.serverIp || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
serverIp: data.user.serverIp || "",
|
serverIp: data.serverIp || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const setCurrentIp = () => {
|
const setCurrentIp = () => {
|
||||||
if (!ip) return;
|
if (!ip) return;
|
||||||
form.setValue("serverIp", ip);
|
form.setValue("serverIp", ip);
|
||||||
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Server IP Updated");
|
toast.success("Server IP Updated");
|
||||||
await utils.user.get.invalidate();
|
await refetch();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -5,18 +6,31 @@ import {
|
|||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} 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" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<Input ref={inputRef} type={"password"} {...props} />
|
<Input ref={inputRef} {...props} type="password" />
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
const AvatarImage = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
|
||||||
>(({ className, ...props }, ref) => (
|
src?: string | null;
|
||||||
<AvatarPrimitive.Image
|
}
|
||||||
ref={ref}
|
>(({ className, src, ...props }, ref) => {
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
if (isSolidColorAvatar(src)) {
|
||||||
{...props}
|
return (
|
||||||
/>
|
<div
|
||||||
));
|
key={`solid-${src}`}
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full rounded-full", className)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: src,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
src={src ?? ""}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
const AvatarFallback = React.forwardRef<
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { generateRandomPassword } from "@/lib/password-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
enablePasswordGenerator?: boolean;
|
||||||
|
passwordGeneratorLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, errorMessage, type, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
errorMessage,
|
||||||
|
type,
|
||||||
|
enablePasswordGenerator = false,
|
||||||
|
passwordGeneratorLength,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
|
const shouldShowGenerator =
|
||||||
|
isPassword &&
|
||||||
|
enablePasswordGenerator !== false &&
|
||||||
|
!props.disabled &&
|
||||||
|
!props.readOnly;
|
||||||
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLInputElement | null) => {
|
||||||
|
// @ts-ignore
|
||||||
|
inputRef.current = node;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGeneratePassword = () => {
|
||||||
|
const nextValue =
|
||||||
|
typeof passwordGeneratorLength === "number" &&
|
||||||
|
passwordGeneratorLength > 0
|
||||||
|
? generateRandomPassword(Math.floor(passwordGeneratorLength))
|
||||||
|
: generateRandomPassword();
|
||||||
|
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
if (valueSetter) {
|
||||||
|
valueSetter.call(input, nextValue);
|
||||||
|
} else {
|
||||||
|
input.value = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
// bg-gray
|
// bg-gray
|
||||||
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isPassword && "pr-10", // Add padding for the eye icon
|
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
{isPassword && (
|
||||||
<button
|
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||||
type="button"
|
{shouldShowGenerator && (
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
<button
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
type="button"
|
||||||
tabIndex={-1}
|
className="hover:text-foreground focus:outline-none"
|
||||||
>
|
onClick={handleGeneratePassword}
|
||||||
{showPassword ? (
|
aria-label="Generate password"
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
title="Generate password"
|
||||||
) : (
|
tabIndex={-1}
|
||||||
<EyeIcon className="h-4 w-4" />
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground focus:outline-none"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
|||||||
84
apps/dokploy/components/ui/number-input.tsx
Normal file
84
apps/dokploy/components/ui/number-input.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export interface UnitConverter {
|
||||||
|
toValue: (raw: string | undefined) => number;
|
||||||
|
fromValue: (value: number) => string;
|
||||||
|
formatDisplay: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createConverter = (
|
||||||
|
multiplier: number,
|
||||||
|
formatDisplay: (value: number) => string,
|
||||||
|
): UnitConverter => ({
|
||||||
|
toValue: (raw) => {
|
||||||
|
if (!raw) return 0;
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
return Number.isNaN(value) ? 0 : value / multiplier;
|
||||||
|
},
|
||||||
|
fromValue: (value) =>
|
||||||
|
value <= 0 ? "" : String(Math.round(value * multiplier)),
|
||||||
|
formatDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NumberInputWithStepsProps {
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
step: number;
|
||||||
|
converter: UnitConverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberInputWithSteps = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
step,
|
||||||
|
converter,
|
||||||
|
}: NumberInputWithStepsProps) => {
|
||||||
|
const numericValue = converter.toValue(value);
|
||||||
|
const displayValue = converter.formatDisplay(numericValue);
|
||||||
|
|
||||||
|
const handleIncrement = () =>
|
||||||
|
onChange(converter.fromValue(numericValue + step));
|
||||||
|
const handleDecrement = () =>
|
||||||
|
onChange(converter.fromValue(Math.max(0, numericValue - step)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={numericValue <= 0}
|
||||||
|
>
|
||||||
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
onClick={handleIncrement}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{displayValue && (
|
||||||
|
<span className="text-xs text-muted-foreground text-center">
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
CREATE TABLE "webServerSettings" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"serverIp" text,
|
||||||
|
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
|
||||||
|
"https" boolean DEFAULT false NOT NULL,
|
||||||
|
"host" text,
|
||||||
|
"letsEncryptEmail" text,
|
||||||
|
"sshPrivateKey" text,
|
||||||
|
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
|
||||||
|
"logCleanupCron" text DEFAULT '0 0 * * *',
|
||||||
|
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
|
||||||
|
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
|
||||||
|
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
|
||||||
|
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data from user table to webServerSettings
|
||||||
|
-- Get the owner user's data and insert into webServerSettings
|
||||||
|
INSERT INTO "webServerSettings" (
|
||||||
|
"id",
|
||||||
|
"serverIp",
|
||||||
|
"certificateType",
|
||||||
|
"https",
|
||||||
|
"host",
|
||||||
|
"letsEncryptEmail",
|
||||||
|
"sshPrivateKey",
|
||||||
|
"enableDockerCleanup",
|
||||||
|
"logCleanupCron",
|
||||||
|
"metricsConfig",
|
||||||
|
"cleanupCacheApplications",
|
||||||
|
"cleanupCacheOnPreviews",
|
||||||
|
"cleanupCacheOnCompose",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text as "id",
|
||||||
|
u."serverIp",
|
||||||
|
COALESCE(u."certificateType", 'none') as "certificateType",
|
||||||
|
COALESCE(u."https", false) as "https",
|
||||||
|
u."host",
|
||||||
|
u."letsEncryptEmail",
|
||||||
|
u."sshPrivateKey",
|
||||||
|
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
|
||||||
|
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
|
||||||
|
COALESCE(
|
||||||
|
u."metricsConfig",
|
||||||
|
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
|
||||||
|
) as "metricsConfig",
|
||||||
|
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
|
||||||
|
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
|
||||||
|
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
|
||||||
|
NOW() as "created_at",
|
||||||
|
NOW() as "updated_at"
|
||||||
|
FROM "user" u
|
||||||
|
INNER JOIN "member" m ON u."id" = m."user_id"
|
||||||
|
WHERE m."role" = 'owner'
|
||||||
|
ORDER BY m."created_at" ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If no owner found, create a default entry
|
||||||
|
INSERT INTO "webServerSettings" (
|
||||||
|
"id",
|
||||||
|
"serverIp",
|
||||||
|
"certificateType",
|
||||||
|
"https",
|
||||||
|
"host",
|
||||||
|
"letsEncryptEmail",
|
||||||
|
"sshPrivateKey",
|
||||||
|
"enableDockerCleanup",
|
||||||
|
"logCleanupCron",
|
||||||
|
"metricsConfig",
|
||||||
|
"cleanupCacheApplications",
|
||||||
|
"cleanupCacheOnPreviews",
|
||||||
|
"cleanupCacheOnCompose",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text as "id",
|
||||||
|
NULL as "serverIp",
|
||||||
|
'none' as "certificateType",
|
||||||
|
false as "https",
|
||||||
|
NULL as "host",
|
||||||
|
NULL as "letsEncryptEmail",
|
||||||
|
NULL as "sshPrivateKey",
|
||||||
|
true as "enableDockerCleanup",
|
||||||
|
'0 0 * * *' as "logCleanupCron",
|
||||||
|
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
|
||||||
|
false as "cleanupCacheApplications",
|
||||||
|
false as "cleanupCacheOnPreviews",
|
||||||
|
false as "cleanupCacheOnCompose",
|
||||||
|
NOW() as "created_at",
|
||||||
|
NOW() as "updated_at"
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "webServerSettings"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";
|
||||||
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';
|
||||||
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -932,6 +932,20 @@
|
|||||||
"when": 1765346573500,
|
"when": 1765346573500,
|
||||||
"tag": "0132_clean_layla_miller",
|
"tag": "0132_clean_layla_miller",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 133,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766301478005,
|
||||||
|
"tag": "0133_striped_the_order",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 134,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767871040249,
|
||||||
|
"tag": "0134_strong_hercules",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
30
apps/dokploy/lib/avatar-utils.ts
Normal file
30
apps/dokploy/lib/avatar-utils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Checks if the given avatar value represents a solid color in hexadecimal format.
|
||||||
|
*
|
||||||
|
* @param value Avatar value to check.
|
||||||
|
*
|
||||||
|
* @return True if the avatar is a solid color, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isSolidColorAvatar(value?: string | null) {
|
||||||
|
return (
|
||||||
|
(value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) ||
|
||||||
|
value?.startsWith("color:") ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the avatar type for form selection (RadioGroup value).
|
||||||
|
*
|
||||||
|
* @param value Avatar value.
|
||||||
|
*
|
||||||
|
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
|
||||||
|
*/
|
||||||
|
export function getAvatarType(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
|
||||||
|
if (value.startsWith("data:")) return "upload";
|
||||||
|
if (isSolidColorAvatar(value)) return "color";
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
38
apps/dokploy/lib/password-utils.ts
Normal file
38
apps/dokploy/lib/password-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const DEFAULT_PASSWORD_LENGTH = 20;
|
||||||
|
const DEFAULT_PASSWORD_CHARSET =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
|
||||||
|
export const generateRandomPassword = (
|
||||||
|
length: number = DEFAULT_PASSWORD_LENGTH,
|
||||||
|
charset: string = DEFAULT_PASSWORD_CHARSET,
|
||||||
|
) => {
|
||||||
|
const safeLength =
|
||||||
|
Number.isFinite(length) && length > 0
|
||||||
|
? Math.floor(length)
|
||||||
|
: DEFAULT_PASSWORD_LENGTH;
|
||||||
|
|
||||||
|
if (safeLength <= 0 || charset.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cryptoApi =
|
||||||
|
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||||
|
|
||||||
|
if (!cryptoApi?.getRandomValues) {
|
||||||
|
let fallback = "";
|
||||||
|
for (let i = 0; i < safeLength; i += 1) {
|
||||||
|
fallback += charset[Math.floor(Math.random() * charset.length)];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = new Uint32Array(safeLength);
|
||||||
|
cryptoApi.getRandomValues(values);
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
for (const value of values) {
|
||||||
|
result += charset[value % charset.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.26.2",
|
"version": "v0.26.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -109,7 +109,6 @@
|
|||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@@ -126,7 +125,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"otpauth": "^9.4.0",
|
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
@@ -140,7 +138,6 @@
|
|||||||
"react-i18next": "^15.5.2",
|
"react-i18next": "^15.5.2",
|
||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"rotating-file-stream": "3.2.3",
|
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
@@ -156,9 +153,11 @@
|
|||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.32",
|
||||||
"zod-form-data": "^2.0.7"
|
"zod-form-data": "^2.0.7",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -898,7 +909,9 @@ const EnvironmentPage = (
|
|||||||
<ProjectEnvironment projectId={projectId}>
|
<ProjectEnvironment projectId={projectId}>
|
||||||
<Button variant="outline">Project Environment</Button>
|
<Button variant="outline">Project Environment</Button>
|
||||||
</ProjectEnvironment>
|
</ProjectEnvironment>
|
||||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canCreateServices) && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -1021,6 +1034,7 @@ const EnvironmentPage = (
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
{(auth?.role === "owner" ||
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
auth?.canDeleteServices) && (
|
auth?.canDeleteServices) && (
|
||||||
<>
|
<>
|
||||||
<DialogAction
|
<DialogAction
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -183,7 +192,9 @@ const Service = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateApplication applicationId={applicationId} />
|
<UpdateApplication applicationId={applicationId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={applicationId} type="application" />
|
<DeleteService id={applicationId} type="application" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -174,7 +182,9 @@ const Service = (
|
|||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateCompose composeId={composeId} />
|
<UpdateCompose composeId={composeId} />
|
||||||
|
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={composeId} type="compose" />
|
<DeleteService id={composeId} type="compose" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -147,7 +156,9 @@ const Mariadb = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMariadb mariadbId={mariadbId} />
|
<UpdateMariadb mariadbId={mariadbId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mariadbId} type="mariadb" />
|
<DeleteService id={mariadbId} type="mariadb" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -147,7 +155,9 @@ const Mongo = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMongo mongoId={mongoId} />
|
<UpdateMongo mongoId={mongoId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mongoId} type="mongo" />
|
<DeleteService id={mongoId} type="mongo" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -148,7 +156,9 @@ const MySql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMysql mysqlId={mysqlId} />
|
<UpdateMysql mysqlId={mysqlId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mysqlId} type="mysql" />
|
<DeleteService id={mysqlId} type="mysql" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -146,7 +154,9 @@ const Postgresql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdatePostgres postgresId={postgresId} />
|
<UpdatePostgres postgresId={postgresId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={postgresId} type="postgres" />
|
<DeleteService id={postgresId} type="postgres" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 || "",
|
||||||
@@ -146,7 +154,9 @@ const Redis = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateRedis redisId={redisId} />
|
<UpdateRedis redisId={redisId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={redisId} type="redis" />
|
<DeleteService id={redisId} type="redis" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
63
apps/dokploy/pages/dashboard/settings/invoices.tsx
Normal file
63
apps/dokploy/pages/dashboard/settings/invoices.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <ShowBillingInvoices />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const { user, session } = await validateRequest(req);
|
||||||
|
if (!user || user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
await helpers.settings.isCloud.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
findUserById,
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
setupWebMonitoring,
|
setupWebMonitoring,
|
||||||
updateUser,
|
updateWebServerSettings,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
|
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
|
||||||
@@ -11,7 +11,7 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
|
|||||||
export const adminRouter = createTRPCRouter({
|
export const adminRouter = createTRPCRouter({
|
||||||
setupMonitoring: adminProcedure
|
setupMonitoring: adminProcedure
|
||||||
.input(apiUpdateWebServerMonitoring)
|
.input(apiUpdateWebServerMonitoring)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -19,15 +19,8 @@ export const adminRouter = createTRPCRouter({
|
|||||||
message: "Feature disabled on cloud",
|
message: "Feature disabled on cloud",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
|
||||||
if (user.id !== ctx.user.ownerId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "You are not authorized to setup the monitoring",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateWebServerSettings({
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
server: {
|
server: {
|
||||||
type: "Dokploy",
|
type: "Dokploy",
|
||||||
@@ -52,8 +45,9 @@ export const adminRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentServer = await setupWebMonitoring(user.id);
|
await setupWebMonitoring();
|
||||||
return currentServer;
|
const settings = await getWebServerSettings();
|
||||||
|
return settings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,40 @@ export const aiRouter = createTRPCRouter({
|
|||||||
{ headers: {} },
|
{ headers: {} },
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "perplexity":
|
||||||
|
// Perplexity doesn't have a /models endpoint, return hardcoded list
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "sonar-deep-research",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "perplexity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonar-reasoning-pro",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "perplexity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonar-reasoning",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "perplexity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonar-pro",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "perplexity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sonar",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "perplexity",
|
||||||
|
},
|
||||||
|
] as Model[];
|
||||||
default:
|
default:
|
||||||
if (!input.apiKey)
|
if (!input.apiKey)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const backup = await findBackupById(input.backupId);
|
const backup = await findBackupById(input.backupId);
|
||||||
await runWebServerBackup(backup);
|
await runWebServerBackup(backup);
|
||||||
|
await keepLatestNBackups(backup);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
listBackupFiles: protectedProcedure
|
listBackupFiles: protectedProcedure
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
findGitProviderById,
|
findGitProviderById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
findServerById,
|
findServerById,
|
||||||
findUserById,
|
|
||||||
getComposeContainer,
|
getComposeContainer,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
loadServices,
|
loadServices,
|
||||||
randomizeComposeFile,
|
randomizeComposeFile,
|
||||||
@@ -430,7 +430,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { success: true, message: "Deployment queued" };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Deployment queued",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
redeploy: protectedProcedure
|
redeploy: protectedProcedure
|
||||||
.input(apiRedeployCompose)
|
.input(apiRedeployCompose)
|
||||||
@@ -468,7 +472,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { success: true, message: "Redeployment queued" };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Redeployment queued",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
stop: protectedProcedure
|
stop: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
@@ -569,8 +577,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const template = await fetchTemplateFiles(input.id, input.baseUrl);
|
const template = await fetchTemplateFiles(input.id, input.baseUrl);
|
||||||
|
|
||||||
const admin = await findUserById(ctx.user.ownerId);
|
let serverIp = "127.0.0.1";
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
|
||||||
|
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
|
|
||||||
@@ -579,6 +586,9 @@ export const composeRouter = createTRPCRouter({
|
|||||||
serverIp = server.ipAddress;
|
serverIp = server.ipAddress;
|
||||||
} else if (process.env.NODE_ENV === "development") {
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
serverIp = "127.0.0.1";
|
serverIp = "127.0.0.1";
|
||||||
|
} else {
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
serverIp = settings?.serverIp || "127.0.0.1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectName = slugify(`${project.name} ${input.id}`);
|
const projectName = slugify(`${project.name} ${input.id}`);
|
||||||
@@ -803,14 +813,16 @@ export const composeRouter = createTRPCRouter({
|
|||||||
const decodedData = Buffer.from(input.base64, "base64").toString(
|
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
const admin = await findUserById(ctx.user.ownerId);
|
let serverIp = "127.0.0.1";
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
|
||||||
|
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
const server = await findServerById(compose.serverId);
|
const server = await findServerById(compose.serverId);
|
||||||
serverIp = server.ipAddress;
|
serverIp = server.ipAddress;
|
||||||
} else if (process.env.NODE_ENV === "development") {
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
serverIp = "127.0.0.1";
|
serverIp = "127.0.0.1";
|
||||||
|
} else {
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
serverIp = settings?.serverIp || "127.0.0.1";
|
||||||
}
|
}
|
||||||
const templateData = JSON.parse(decodedData);
|
const templateData = JSON.parse(decodedData);
|
||||||
const config = parse(templateData.config) as CompleteTemplate;
|
const config = parse(templateData.config) as CompleteTemplate;
|
||||||
@@ -880,14 +892,16 @@ export const composeRouter = createTRPCRouter({
|
|||||||
await removeDomainById(domain.domainId);
|
await removeDomainById(domain.domainId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = await findUserById(ctx.user.ownerId);
|
let serverIp = "127.0.0.1";
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
|
||||||
|
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
const server = await findServerById(compose.serverId);
|
const server = await findServerById(compose.serverId);
|
||||||
serverIp = server.ipAddress;
|
serverIp = server.ipAddress;
|
||||||
} else if (process.env.NODE_ENV === "development") {
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
serverIp = "127.0.0.1";
|
serverIp = "127.0.0.1";
|
||||||
|
} else {
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
serverIp = settings?.serverIp || "127.0.0.1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateData = JSON.parse(decodedData);
|
const templateData = JSON.parse(decodedData);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
findServerById,
|
findServerById,
|
||||||
generateTraefikMeDomain,
|
generateTraefikMeDomain,
|
||||||
|
getWebServerSettings,
|
||||||
manageDomain,
|
manageDomain,
|
||||||
removeDomain,
|
removeDomain,
|
||||||
removeDomainById,
|
removeDomainById,
|
||||||
@@ -107,16 +108,13 @@ export const domainRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
canGenerateTraefikMeDomains: protectedProcedure
|
canGenerateTraefikMeDomains: protectedProcedure
|
||||||
.input(z.object({ serverId: z.string() }))
|
.input(z.object({ serverId: z.string() }))
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input }) => {
|
||||||
const organization = await findOrganizationById(
|
|
||||||
ctx.session.activeOrganizationId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (input.serverId) {
|
if (input.serverId) {
|
||||||
const server = await findServerById(input.serverId);
|
const server = await findServerById(input.serverId);
|
||||||
return server.ipAddress;
|
return server.ipAddress;
|
||||||
}
|
}
|
||||||
return organization?.owner.serverIp;
|
const settings = await getWebServerSettings();
|
||||||
|
return settings?.serverIp || "";
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createSlackNotification,
|
createSlackNotification,
|
||||||
createTelegramNotification,
|
createTelegramNotification,
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeNotificationById,
|
removeNotificationById,
|
||||||
sendCustomNotification,
|
sendCustomNotification,
|
||||||
@@ -66,7 +67,6 @@ import {
|
|||||||
apiUpdateTelegram,
|
apiUpdateTelegram,
|
||||||
notifications,
|
notifications,
|
||||||
server,
|
server,
|
||||||
user,
|
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
export const notificationRouter = createTRPCRouter({
|
export const notificationRouter = createTRPCRouter({
|
||||||
@@ -364,21 +364,20 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
let organizationId = "";
|
let organizationId = "";
|
||||||
let ServerName = "";
|
let ServerName = "";
|
||||||
if (input.ServerType === "Dokploy") {
|
if (input.ServerType === "Dokploy") {
|
||||||
const result = await db
|
const settings = await getWebServerSettings();
|
||||||
.select()
|
if (
|
||||||
.from(user)
|
!settings?.metricsConfig?.server?.token ||
|
||||||
.where(
|
settings.metricsConfig.server.token !== input.Token
|
||||||
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
) {
|
||||||
);
|
|
||||||
|
|
||||||
if (!result?.[0]?.id) {
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Token not found",
|
message: "Token not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
organizationId = result?.[0]?.id;
|
// For Dokploy server type, we don't have a specific organizationId
|
||||||
|
// This might need to be adjusted based on your business logic
|
||||||
|
organizationId = "";
|
||||||
ServerName = "Dokploy";
|
ServerName = "Dokploy";
|
||||||
} else {
|
} else {
|
||||||
const result = await db
|
const result = await db
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import {
|
|||||||
findApplicationById,
|
findApplicationById,
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
findPreviewDeploymentsByApplicationId,
|
findPreviewDeploymentsByApplicationId,
|
||||||
|
IS_CLOUD,
|
||||||
removePreviewDeployment,
|
removePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiFindAllByApplication } from "@/server/db/schema";
|
import { apiFindAllByApplication } from "@/server/db/schema";
|
||||||
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
|
import { deploy } from "@/server/utils/deploy";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const previewDeploymentRouter = createTRPCRouter({
|
export const previewDeploymentRouter = createTRPCRouter({
|
||||||
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return previewDeployment;
|
return previewDeployment;
|
||||||
}),
|
}),
|
||||||
|
redeploy: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
previewDeploymentId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const previewDeployment = await findPreviewDeploymentById(
|
||||||
|
input.previewDeploymentId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
previewDeployment.application.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to redeploy this preview deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const application = await findApplicationById(
|
||||||
|
previewDeployment.applicationId,
|
||||||
|
);
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: previewDeployment.applicationId,
|
||||||
|
titleLog: input.title || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: input.description || "",
|
||||||
|
type: "redeploy",
|
||||||
|
applicationType: "application-preview",
|
||||||
|
previewDeploymentId: input.previewDeploymentId,
|
||||||
|
server: !!application.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && application.serverId) {
|
||||||
|
jobData.serverId = application.serverId;
|
||||||
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import {
|
|||||||
DEFAULT_UPDATE_DATA,
|
DEFAULT_UPDATE_DATA,
|
||||||
execAsync,
|
execAsync,
|
||||||
findServerById,
|
findServerById,
|
||||||
findUserById,
|
|
||||||
getDokployImage,
|
getDokployImage,
|
||||||
getDokployImageTag,
|
getDokployImageTag,
|
||||||
getLogCleanupStatus,
|
getLogCleanupStatus,
|
||||||
getUpdateData,
|
getUpdateData,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
parseRawConfig,
|
parseRawConfig,
|
||||||
paths,
|
paths,
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
updateLetsEncryptEmail,
|
updateLetsEncryptEmail,
|
||||||
updateServerById,
|
updateServerById,
|
||||||
updateServerTraefik,
|
updateServerTraefik,
|
||||||
updateUser,
|
updateWebServerSettings,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
writeMainConfig,
|
writeMainConfig,
|
||||||
writeTraefikConfigInPath,
|
writeTraefikConfigInPath,
|
||||||
@@ -77,11 +77,18 @@ import {
|
|||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
|
||||||
export const settingsRouter = createTRPCRouter({
|
export const settingsRouter = createTRPCRouter({
|
||||||
|
getWebServerSettings: protectedProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
return settings;
|
||||||
|
}),
|
||||||
reloadServer: adminProcedure.mutation(async () => {
|
reloadServer: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await reloadDockerResource("dokploy");
|
await reloadDockerResource("dokploy", undefined, packageInfo.version);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanRedis: adminProcedure.mutation(async () => {
|
cleanRedis: adminProcedure.mutation(async () => {
|
||||||
@@ -209,11 +216,11 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
saveSSHPrivateKey: adminProcedure
|
saveSSHPrivateKey: adminProcedure
|
||||||
.input(apiSaveSSHKey)
|
.input(apiSaveSSHKey)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.ownerId, {
|
await updateWebServerSettings({
|
||||||
sshPrivateKey: input.sshPrivateKey,
|
sshPrivateKey: input.sshPrivateKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,36 +228,36 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
assignDomainServer: adminProcedure
|
assignDomainServer: adminProcedure
|
||||||
.input(apiAssignDomain)
|
.input(apiAssignDomain)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ input }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const user = await updateUser(ctx.user.ownerId, {
|
const settings = await updateWebServerSettings({
|
||||||
host: input.host,
|
host: input.host,
|
||||||
letsEncryptEmail: input.letsEncryptEmail,
|
letsEncryptEmail: input.letsEncryptEmail,
|
||||||
certificateType: input.certificateType,
|
certificateType: input.certificateType,
|
||||||
https: input.https,
|
https: input.https,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!settings) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "User not found",
|
message: "Web server settings not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateServerTraefik(user, input.host);
|
updateServerTraefik(settings, input.host);
|
||||||
if (input.letsEncryptEmail) {
|
if (input.letsEncryptEmail) {
|
||||||
updateLetsEncryptEmail(input.letsEncryptEmail);
|
updateLetsEncryptEmail(input.letsEncryptEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return settings;
|
||||||
}),
|
}),
|
||||||
cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => {
|
cleanSSHPrivateKey: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.ownerId, {
|
await updateWebServerSettings({
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -310,11 +317,11 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!IS_CLOUD) {
|
} else if (!IS_CLOUD) {
|
||||||
const userUpdated = await updateUser(ctx.user.ownerId, {
|
const settingsUpdated = await updateWebServerSettings({
|
||||||
enableDockerCleanup: input.enableDockerCleanup,
|
enableDockerCleanup: input.enableDockerCleanup,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userUpdated?.enableDockerCleanup) {
|
if (settingsUpdated?.enableDockerCleanup) {
|
||||||
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
|
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||||
@@ -392,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getUpdateData();
|
return await getUpdateData(packageInfo.version);
|
||||||
}),
|
}),
|
||||||
updateServer: adminProcedure.mutation(async () => {
|
updateServer: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
@@ -488,13 +495,28 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return readConfigInPath(input.path, input.serverId);
|
return readConfigInPath(input.path, input.serverId);
|
||||||
}),
|
}),
|
||||||
getIp: protectedProcedure.query(async ({ ctx }) => {
|
getIp: protectedProcedure.query(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return "";
|
||||||
}
|
}
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
const settings = await getWebServerSettings();
|
||||||
return user.serverIp;
|
return settings?.serverIp || "";
|
||||||
}),
|
}),
|
||||||
|
updateServerIp: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
serverIp: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const settings = await updateWebServerSettings({
|
||||||
|
serverIp: input.serverIp,
|
||||||
|
});
|
||||||
|
return settings;
|
||||||
|
}),
|
||||||
|
|
||||||
getOpenApiDocument: protectedProcedure.query(
|
getOpenApiDocument: protectedProcedure.query(
|
||||||
async ({ ctx }): Promise<unknown> => {
|
async ({ ctx }): Promise<unknown> => {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
metadata: {
|
metadata: {
|
||||||
adminId: owner.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
|
customer_email: owner.email,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||||
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||||
@@ -128,4 +129,39 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return servers.length < user.serversQuantity;
|
return servers.length < user.serversQuantity;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoices = await stripe.invoices.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoices.data.map((invoice) => ({
|
||||||
|
id: invoice.id,
|
||||||
|
number: invoice.number,
|
||||||
|
status: invoice.status,
|
||||||
|
amountDue: invoice.amount_due,
|
||||||
|
amountPaid: invoice.amount_paid,
|
||||||
|
currency: invoice.currency,
|
||||||
|
created: invoice.created,
|
||||||
|
dueDate: invoice.due_date,
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||||
|
invoicePdf: invoice.invoice_pdf,
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findUserById,
|
findUserById,
|
||||||
getDokployUrl,
|
getDokployUrl,
|
||||||
getUserByToken,
|
getUserByToken,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
@@ -214,10 +215,11 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
|
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
return {
|
return {
|
||||||
serverIp: user.serverIp,
|
serverIp: settings?.serverIp,
|
||||||
enabledFeatures: user.enablePaidFeatures,
|
enabledFeatures: user.enablePaidFeatures,
|
||||||
metricsConfig: user?.metricsConfig,
|
metricsConfig: settings?.metricsConfig,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
remove: protectedProcedure
|
remove: protectedProcedure
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job.data.type === "deploy") {
|
if (job.data.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type DeployJob =
|
|||||||
titleLog: string;
|
titleLog: string;
|
||||||
descriptionLog: string;
|
descriptionLog: string;
|
||||||
server?: boolean;
|
server?: boolean;
|
||||||
type: "deploy";
|
type: "deploy" | "redeploy";
|
||||||
applicationType: "application-preview";
|
applicationType: "application-preview";
|
||||||
previewDeploymentId: string;
|
previewDeploymentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
)
|
)
|
||||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -67,7 +66,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"otpauth": "^9.4.0",
|
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
@@ -75,15 +73,16 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"rotating-file-stream": "3.2.3",
|
|
||||||
"shell-quote": "^1.8.1",
|
"shell-quote": "^1.8.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^3.25.32",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
@@ -112,4 +111,4 @@
|
|||||||
"node": "^20.16.0",
|
"node": "^20.16.0",
|
||||||
"pnpm": ">=9.12.0"
|
"pnpm": ">=9.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ table application {
|
|||||||
replicas integer [not null, default: 1]
|
replicas integer [not null, default: 1]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
buildType buildType [not null, default: 'nixpacks']
|
buildType buildType [not null, default: 'nixpacks']
|
||||||
railpackVersion text [default: '0.2.2']
|
railpackVersion text [default: '0.15.4']
|
||||||
herokuVersion text [default: '24']
|
herokuVersion text [default: '24']
|
||||||
publishDirectory text
|
publishDirectory text
|
||||||
isStaticSpa boolean
|
isStaticSpa boolean
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const applications = pgTable("application", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("idle"),
|
.default("idle"),
|
||||||
buildType: buildType("buildType").notNull().default("nixpacks"),
|
buildType: buildType("buildType").notNull().default("nixpacks"),
|
||||||
railpackVersion: text("railpackVersion").default("0.2.2"),
|
railpackVersion: text("railpackVersion").default("0.15.4"),
|
||||||
herokuVersion: text("herokuVersion").default("24"),
|
herokuVersion: text("herokuVersion").default("24"),
|
||||||
publishDirectory: text("publishDirectory"),
|
publishDirectory: text("publishDirectory"),
|
||||||
isStaticSpa: boolean("isStaticSpa"),
|
isStaticSpa: boolean("isStaticSpa"),
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ export * from "./ssh-key";
|
|||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./volume-backups";
|
export * from "./volume-backups";
|
||||||
|
export * from "./web-server-settings";
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export const apiTestRegistry = createSchema.pick({}).extend({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiTestRegistryById = createSchema
|
||||||
|
.pick({
|
||||||
|
registryId: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiRemoveRegistry = createSchema
|
export const apiRemoveRegistry = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
registryId: true,
|
registryId: true,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { relations } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -15,7 +14,6 @@ import { account, apikey, organization } from "./account";
|
|||||||
import { backups } from "./backups";
|
import { backups } from "./backups";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { certificateType } from "./shared";
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -51,73 +49,10 @@ export const user = pgTable("user", {
|
|||||||
banExpires: timestamp("ban_expires"),
|
banExpires: timestamp("ban_expires"),
|
||||||
updatedAt: timestamp("updated_at").notNull(),
|
updatedAt: timestamp("updated_at").notNull(),
|
||||||
// Admin
|
// Admin
|
||||||
serverIp: text("serverIp"),
|
|
||||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
|
||||||
https: boolean("https").notNull().default(false),
|
|
||||||
host: text("host"),
|
|
||||||
letsEncryptEmail: text("letsEncryptEmail"),
|
|
||||||
sshPrivateKey: text("sshPrivateKey"),
|
|
||||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
|
||||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
|
||||||
role: text("role").notNull().default("user"),
|
role: text("role").notNull().default("user"),
|
||||||
// Metrics
|
// Metrics
|
||||||
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
|
||||||
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
|
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
|
||||||
metricsConfig: jsonb("metricsConfig")
|
|
||||||
.$type<{
|
|
||||||
server: {
|
|
||||||
type: "Dokploy" | "Remote";
|
|
||||||
refreshRate: number;
|
|
||||||
port: number;
|
|
||||||
token: string;
|
|
||||||
urlCallback: string;
|
|
||||||
retentionDays: number;
|
|
||||||
cronJob: string;
|
|
||||||
thresholds: {
|
|
||||||
cpu: number;
|
|
||||||
memory: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
containers: {
|
|
||||||
refreshRate: number;
|
|
||||||
services: {
|
|
||||||
include: string[];
|
|
||||||
exclude: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>()
|
|
||||||
.notNull()
|
|
||||||
.default({
|
|
||||||
server: {
|
|
||||||
type: "Dokploy",
|
|
||||||
refreshRate: 60,
|
|
||||||
port: 4500,
|
|
||||||
token: "",
|
|
||||||
retentionDays: 2,
|
|
||||||
cronJob: "",
|
|
||||||
urlCallback: "",
|
|
||||||
thresholds: {
|
|
||||||
cpu: 0,
|
|
||||||
memory: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
containers: {
|
|
||||||
refreshRate: 60,
|
|
||||||
services: {
|
|
||||||
include: [],
|
|
||||||
exclude: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews")
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
stripeCustomerId: text("stripeCustomerId"),
|
stripeCustomerId: text("stripeCustomerId"),
|
||||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||||
@@ -203,33 +138,6 @@ export const apiFindOneUserByAuth = createSchema
|
|||||||
// authId: true,
|
// authId: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
export const apiSaveSSHKey = createSchema
|
|
||||||
.pick({
|
|
||||||
sshPrivateKey: true,
|
|
||||||
})
|
|
||||||
.required();
|
|
||||||
|
|
||||||
export const apiAssignDomain = createSchema
|
|
||||||
.pick({
|
|
||||||
host: true,
|
|
||||||
certificateType: true,
|
|
||||||
letsEncryptEmail: true,
|
|
||||||
https: true,
|
|
||||||
})
|
|
||||||
.required()
|
|
||||||
.partial({
|
|
||||||
letsEncryptEmail: true,
|
|
||||||
https: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const apiUpdateDockerCleanup = createSchema
|
|
||||||
.pick({
|
|
||||||
enableDockerCleanup: true,
|
|
||||||
})
|
|
||||||
.required()
|
|
||||||
.extend({
|
|
||||||
serverId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const apiTraefikConfig = z.object({
|
export const apiTraefikConfig = z.object({
|
||||||
traefikConfig: z.string().min(1),
|
traefikConfig: z.string().min(1),
|
||||||
@@ -298,32 +206,6 @@ export const apiReadStatsLogs = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUpdateWebServerMonitoring = z.object({
|
|
||||||
metricsConfig: z
|
|
||||||
.object({
|
|
||||||
server: z.object({
|
|
||||||
refreshRate: z.number().min(2),
|
|
||||||
port: z.number().min(1),
|
|
||||||
token: z.string(),
|
|
||||||
urlCallback: z.string().url(),
|
|
||||||
retentionDays: z.number().min(1),
|
|
||||||
cronJob: z.string().min(1),
|
|
||||||
thresholds: z.object({
|
|
||||||
cpu: z.number().min(0),
|
|
||||||
memory: z.number().min(0),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
containers: z.object({
|
|
||||||
refreshRate: z.number().min(2),
|
|
||||||
services: z.object({
|
|
||||||
include: z.array(z.string()).optional(),
|
|
||||||
exclude: z.array(z.string()).optional(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.required(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const apiUpdateUser = createSchema.partial().extend({
|
export const apiUpdateUser = createSchema.partial().extend({
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -334,29 +216,4 @@ export const apiUpdateUser = createSchema.partial().extend({
|
|||||||
currentPassword: z.string().optional(),
|
currentPassword: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
lastName: z.string().optional(),
|
lastName: z.string().optional(),
|
||||||
metricsConfig: z
|
|
||||||
.object({
|
|
||||||
server: z.object({
|
|
||||||
type: z.enum(["Dokploy", "Remote"]),
|
|
||||||
refreshRate: z.number(),
|
|
||||||
port: z.number(),
|
|
||||||
token: z.string(),
|
|
||||||
urlCallback: z.string(),
|
|
||||||
retentionDays: z.number(),
|
|
||||||
cronJob: z.string(),
|
|
||||||
thresholds: z.object({
|
|
||||||
cpu: z.number(),
|
|
||||||
memory: z.number(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
containers: z.object({
|
|
||||||
refreshRate: z.number(),
|
|
||||||
services: z.object({
|
|
||||||
include: z.array(z.string()),
|
|
||||||
exclude: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
logCleanupCron: z.string().optional().nullable(),
|
|
||||||
});
|
});
|
||||||
|
|||||||
178
packages/server/src/db/schema/web-server-settings.ts
Normal file
178
packages/server/src/db/schema/web-server-settings.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { certificateType } from "./shared";
|
||||||
|
|
||||||
|
export const webServerSettings = pgTable("webServerSettings", {
|
||||||
|
id: text("id")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
// Web Server Configuration
|
||||||
|
serverIp: text("serverIp"),
|
||||||
|
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||||
|
https: boolean("https").notNull().default(false),
|
||||||
|
host: text("host"),
|
||||||
|
letsEncryptEmail: text("letsEncryptEmail"),
|
||||||
|
sshPrivateKey: text("sshPrivateKey"),
|
||||||
|
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||||
|
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||||
|
// Metrics Configuration
|
||||||
|
metricsConfig: jsonb("metricsConfig")
|
||||||
|
.$type<{
|
||||||
|
server: {
|
||||||
|
type: "Dokploy" | "Remote";
|
||||||
|
refreshRate: number;
|
||||||
|
port: number;
|
||||||
|
token: string;
|
||||||
|
urlCallback: string;
|
||||||
|
retentionDays: number;
|
||||||
|
cronJob: string;
|
||||||
|
thresholds: {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
containers: {
|
||||||
|
refreshRate: number;
|
||||||
|
services: {
|
||||||
|
include: string[];
|
||||||
|
exclude: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>()
|
||||||
|
.notNull()
|
||||||
|
.default({
|
||||||
|
server: {
|
||||||
|
type: "Dokploy",
|
||||||
|
refreshRate: 60,
|
||||||
|
port: 4500,
|
||||||
|
token: "",
|
||||||
|
retentionDays: 2,
|
||||||
|
cronJob: "",
|
||||||
|
urlCallback: "",
|
||||||
|
thresholds: {
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
refreshRate: 60,
|
||||||
|
services: {
|
||||||
|
include: [],
|
||||||
|
exclude: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Cache Cleanup Configuration
|
||||||
|
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const webServerSettingsRelations = relations(
|
||||||
|
webServerSettings,
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createSchema = createInsertSchema(webServerSettings, {
|
||||||
|
id: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||||
|
serverIp: z.string().optional(),
|
||||||
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
host: z.string().optional(),
|
||||||
|
letsEncryptEmail: z.string().email().optional().nullable(),
|
||||||
|
sshPrivateKey: z.string().optional(),
|
||||||
|
enableDockerCleanup: z.boolean().optional(),
|
||||||
|
logCleanupCron: z.string().optional().nullable(),
|
||||||
|
metricsConfig: z
|
||||||
|
.object({
|
||||||
|
server: z.object({
|
||||||
|
type: z.enum(["Dokploy", "Remote"]),
|
||||||
|
refreshRate: z.number(),
|
||||||
|
port: z.number(),
|
||||||
|
token: z.string(),
|
||||||
|
urlCallback: z.string(),
|
||||||
|
retentionDays: z.number(),
|
||||||
|
cronJob: z.string(),
|
||||||
|
thresholds: z.object({
|
||||||
|
cpu: z.number(),
|
||||||
|
memory: z.number(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
containers: z.object({
|
||||||
|
refreshRate: z.number(),
|
||||||
|
services: z.object({
|
||||||
|
include: z.array(z.string()),
|
||||||
|
exclude: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
cleanupCacheApplications: z.boolean().optional(),
|
||||||
|
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||||
|
cleanupCacheOnCompose: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiAssignDomain = z
|
||||||
|
.object({
|
||||||
|
host: z.string(),
|
||||||
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
|
letsEncryptEmail: z.string().email().optional().nullable(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.required()
|
||||||
|
.partial({
|
||||||
|
letsEncryptEmail: true,
|
||||||
|
https: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiSaveSSHKey = z
|
||||||
|
.object({
|
||||||
|
sshPrivateKey: z.string(),
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export const apiUpdateDockerCleanup = z.object({
|
||||||
|
enableDockerCleanup: z.boolean(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUpdateWebServerMonitoring = z.object({
|
||||||
|
metricsConfig: z
|
||||||
|
.object({
|
||||||
|
server: z.object({
|
||||||
|
refreshRate: z.number().min(2),
|
||||||
|
port: z.number().min(1),
|
||||||
|
token: z.string(),
|
||||||
|
urlCallback: z.string().url(),
|
||||||
|
retentionDays: z.number().min(1),
|
||||||
|
cronJob: z.string().min(1),
|
||||||
|
thresholds: z.object({
|
||||||
|
cpu: z.number().min(0),
|
||||||
|
memory: z.number().min(0),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
containers: z.object({
|
||||||
|
refreshRate: z.number().min(2),
|
||||||
|
services: z.object({
|
||||||
|
include: z.array(z.string()).optional(),
|
||||||
|
exclude: z.array(z.string()).optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.required(),
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ export * from "./services/settings";
|
|||||||
export * from "./services/ssh-key";
|
export * from "./services/ssh-key";
|
||||||
export * from "./services/user";
|
export * from "./services/user";
|
||||||
export * from "./services/volume-backups";
|
export * from "./services/volume-backups";
|
||||||
|
export * from "./services/web-server-settings";
|
||||||
export * from "./setup/config-paths";
|
export * from "./setup/config-paths";
|
||||||
export * from "./setup/monitoring-setup";
|
export * from "./setup/monitoring-setup";
|
||||||
export * from "./setup/postgres-setup";
|
export * from "./setup/postgres-setup";
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { IS_CLOUD } from "../constants";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import * as schema from "../db/schema";
|
import * as schema from "../db/schema";
|
||||||
import { getUserByToken } from "../services/admin";
|
import { getUserByToken } from "../services/admin";
|
||||||
import { updateUser } from "../services/user";
|
import {
|
||||||
|
getWebServerSettings,
|
||||||
|
updateWebServerSettings,
|
||||||
|
} from "../services/web-server-settings";
|
||||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||||
import { sendEmail } from "../verification/send-verification-email";
|
import { sendEmail } from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
@@ -35,22 +38,20 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
...(!IS_CLOUD && {
|
...(!IS_CLOUD && {
|
||||||
async trustedOrigins() {
|
async trustedOrigins() {
|
||||||
const admin = await db.query.member.findFirst({
|
const settings = await getWebServerSettings();
|
||||||
where: eq(schema.member.role, "owner"),
|
if (!settings) {
|
||||||
with: {
|
return [];
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (admin) {
|
|
||||||
return [
|
|
||||||
...(admin.user.serverIp
|
|
||||||
? [`http://${admin.user.serverIp}:3000`]
|
|
||||||
: []),
|
|
||||||
...(admin.user.host ? [`https://${admin.user.host}`] : []),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return [];
|
return [
|
||||||
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||||
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||||
|
...(process.env.NODE_ENV === "development"
|
||||||
|
? [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
@@ -122,7 +123,7 @@ const { handler, api } = betterAuth({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
await updateUser(user.id, {
|
await updateWebServerSettings({
|
||||||
serverIp: await getPublicIpWithFallback(),
|
serverIp: await getPublicIpWithFallback(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { IS_CLOUD } from "../constants";
|
import { IS_CLOUD } from "../constants";
|
||||||
|
import { getWebServerSettings } from "./web-server-settings";
|
||||||
|
|
||||||
export const findUserById = async (userId: string) => {
|
export const findUserById = async (userId: string) => {
|
||||||
const userResult = await db.query.user.findFirst({
|
const userResult = await db.query.user.findFirst({
|
||||||
@@ -107,11 +108,11 @@ export const getDokployUrl = async () => {
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return "https://app.dokploy.com";
|
return "https://app.dokploy.com";
|
||||||
}
|
}
|
||||||
const owner = await findOwner();
|
const settings = await getWebServerSettings();
|
||||||
|
|
||||||
if (owner.user.host) {
|
if (settings?.host) {
|
||||||
const protocol = owner.user.https ? "https" : "http";
|
const protocol = settings?.https ? "https" : "http";
|
||||||
return `${protocol}://${owner.user.host}`;
|
return `${protocol}://${settings?.host}`;
|
||||||
}
|
}
|
||||||
return `http://${owner.user.serverIp}:${process.env.PORT}`;
|
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { generateObject } from "ai";
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { IS_CLOUD } from "../constants";
|
import { IS_CLOUD } from "../constants";
|
||||||
import { findOrganizationById } from "./admin";
|
|
||||||
import { findServerById } from "./server";
|
import { findServerById } from "./server";
|
||||||
|
import { getWebServerSettings } from "./web-server-settings";
|
||||||
|
|
||||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||||
const aiSettings = await db.query.ai.findMany({
|
const aiSettings = await db.query.ai.findMany({
|
||||||
@@ -79,8 +79,8 @@ export const suggestVariants = async ({
|
|||||||
|
|
||||||
let ip = "";
|
let ip = "";
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
const organization = await findOrganizationById(organizationId);
|
const settings = await getWebServerSettings();
|
||||||
ip = organization?.owner.serverIp || "";
|
ip = settings?.serverIp || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
|
|||||||
@@ -452,6 +452,137 @@ export const deployPreviewApplication = async ({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rebuildPreviewApplication = async ({
|
||||||
|
applicationId,
|
||||||
|
titleLog = "Rebuild Preview Deployment",
|
||||||
|
descriptionLog = "",
|
||||||
|
previewDeploymentId,
|
||||||
|
}: {
|
||||||
|
applicationId: string;
|
||||||
|
titleLog: string;
|
||||||
|
descriptionLog: string;
|
||||||
|
previewDeploymentId: string;
|
||||||
|
}) => {
|
||||||
|
const application = await findApplicationById(applicationId);
|
||||||
|
const previewDeployment =
|
||||||
|
await findPreviewDeploymentById(previewDeploymentId);
|
||||||
|
|
||||||
|
const deployment = await createDeploymentPreview({
|
||||||
|
title: titleLog,
|
||||||
|
description: descriptionLog,
|
||||||
|
previewDeploymentId: previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
|
||||||
|
const issueParams = {
|
||||||
|
owner: application?.owner || "",
|
||||||
|
repository: application?.repository || "",
|
||||||
|
issue_number: previewDeployment.pullRequestNumber,
|
||||||
|
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentExists = await issueCommentExists({
|
||||||
|
...issueParams,
|
||||||
|
});
|
||||||
|
if (!commentExists) {
|
||||||
|
const result = await createPreviewDeploymentComment({
|
||||||
|
...issueParams,
|
||||||
|
previewDomain,
|
||||||
|
appName: previewDeployment.appName,
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Pull request comment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildingComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"running",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set application properties for preview deployment
|
||||||
|
application.appName = previewDeployment.appName;
|
||||||
|
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.rollbackActive = false;
|
||||||
|
application.buildRegistry = null;
|
||||||
|
application.rollbackRegistry = null;
|
||||||
|
application.registry = null;
|
||||||
|
|
||||||
|
const serverId = application.serverId;
|
||||||
|
let command = "set -e;";
|
||||||
|
// Only rebuild, don't clone repository
|
||||||
|
command += await getBuildCommand(application);
|
||||||
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, commandWithLog);
|
||||||
|
} else {
|
||||||
|
await execAsync(commandWithLog);
|
||||||
|
}
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
const successComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"success",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${successComment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "done",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
let command = "";
|
||||||
|
|
||||||
|
// Only log details for non-ExecError errors
|
||||||
|
if (!(error instanceof ExecError)) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const encodedMessage = encodeBase64(message);
|
||||||
|
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
|
||||||
|
const serverId = application.buildServerId || application.serverId;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = getIssueComment(application.name, "error", previewDomain);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${comment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "error",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const getApplicationStats = async (appName: string) => {
|
export const getApplicationStats = async (appName: string) => {
|
||||||
if (appName === "dokploy") {
|
if (appName === "dokploy") {
|
||||||
return await getAdvancedStats(appName);
|
return await getAdvancedStats(appName);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import dns from "node:dns";
|
import dns from "node:dns";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type apiCreateDomain, domains } from "../db/schema";
|
import { type apiCreateDomain, domains } from "../db/schema";
|
||||||
import { findUserById } from "./admin";
|
|
||||||
import { findApplicationById } from "./application";
|
import { findApplicationById } from "./application";
|
||||||
import { detectCDNProvider } from "./cdn";
|
import { detectCDNProvider } from "./cdn";
|
||||||
import { findServerById } from "./server";
|
import { findServerById } from "./server";
|
||||||
@@ -61,9 +61,9 @@ export const generateTraefikMeDomain = async (
|
|||||||
projectName: appName,
|
projectName: appName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const admin = await findUserById(userId);
|
const settings = await getWebServerSettings();
|
||||||
return generateRandomDomain({
|
return generateRandomDomain({
|
||||||
serverIp: admin?.serverIp || "",
|
serverIp: settings?.serverIp || "",
|
||||||
projectName: appName,
|
projectName: appName,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import { removeDirectoryCode } from "../utils/filesystem/directory";
|
|||||||
import { authGithub } from "../utils/providers/github";
|
import { authGithub } from "../utils/providers/github";
|
||||||
import { removeTraefikConfig } from "../utils/traefik/application";
|
import { removeTraefikConfig } from "../utils/traefik/application";
|
||||||
import { manageDomain } from "../utils/traefik/domain";
|
import { manageDomain } from "../utils/traefik/domain";
|
||||||
import { findUserById } from "./admin";
|
|
||||||
import { findApplicationById } from "./application";
|
import { findApplicationById } from "./application";
|
||||||
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
|
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
|
||||||
import { createDomain } from "./domain";
|
import { createDomain } from "./domain";
|
||||||
import { type Github, getIssueComment } from "./github";
|
import { type Github, getIssueComment } from "./github";
|
||||||
|
import { getWebServerSettings } from "./web-server-settings";
|
||||||
|
|
||||||
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
|
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
|
||||||
|
|
||||||
@@ -253,8 +253,8 @@ const generateWildcardDomain = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
const admin = await findUserById(userId);
|
const settings = await getWebServerSettings();
|
||||||
ip = admin?.serverIp || "";
|
ip = settings?.serverIp || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugIp = ip.replaceAll(".", "-");
|
const slugIp = ip.replaceAll(".", "-");
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
initializeStandaloneTraefik,
|
initializeStandaloneTraefik,
|
||||||
initializeTraefikService,
|
initializeTraefikService,
|
||||||
type TraefikOptions,
|
type TraefikOptions,
|
||||||
} from "../setup/traefik-setup";
|
} from "../setup/traefik-setup";
|
||||||
|
|
||||||
export interface IUpdateData {
|
export interface IUpdateData {
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
@@ -55,56 +55,95 @@ export const getServiceImageDigest = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
||||||
export const getUpdateData = async (): Promise<IUpdateData> => {
|
export const getUpdateData = async (
|
||||||
let currentDigest: string;
|
currentVersion: string,
|
||||||
|
): Promise<IUpdateData> => {
|
||||||
try {
|
try {
|
||||||
currentDigest = await getServiceImageDigest();
|
const baseUrl =
|
||||||
} catch (error) {
|
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
||||||
// TODO: Docker versions 29.0.0 change the way to get the service image digest, so we need to update this in the future we upgrade to that version.
|
let url: string | null = `${baseUrl}?page_size=100`;
|
||||||
return DEFAULT_UPDATE_DATA;
|
let allResults: { digest: string; name: string }[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
// Fetch all tags from Docker Hub
|
||||||
let url: string | null = `${baseUrl}?page_size=100`;
|
while (url) {
|
||||||
let allResults: { digest: string; name: string }[] = [];
|
const response = await fetch(url, {
|
||||||
while (url) {
|
method: "GET",
|
||||||
const response = await fetch(url, {
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "GET",
|
});
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
next: string | null;
|
next: string | null;
|
||||||
results: { digest: string; name: string }[];
|
results: { digest: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
allResults = allResults.concat(data.results);
|
allResults = allResults.concat(data.results);
|
||||||
url = data?.next;
|
url = data?.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageTag = getDokployImageTag();
|
const currentImageTag = getDokployImageTag();
|
||||||
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
|
|
||||||
|
|
||||||
if (!searchedDigest) {
|
// Special handling for canary and feature branches
|
||||||
return DEFAULT_UPDATE_DATA;
|
// For development versions (canary/feature), don't perform update checks
|
||||||
}
|
// These are unstable versions that change frequently, and users on these
|
||||||
|
// branches are expected to manually manage updates
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
const currentDigest = await getServiceImageDigest();
|
||||||
|
const latestDigest = allResults.find(
|
||||||
|
(t) => t.name === currentImageTag,
|
||||||
|
)?.digest;
|
||||||
|
if (!latestDigest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
if (currentDigest !== latestDigest) {
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (imageTag === "latest") {
|
// For stable versions, use semver comparison
|
||||||
const versionedTag = allResults.find(
|
// Find the "latest" tag and get its digest
|
||||||
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
|
const latestTag = allResults.find((t) => t.name === "latest");
|
||||||
);
|
|
||||||
|
|
||||||
if (!versionedTag) {
|
if (!latestTag) {
|
||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name: latestVersion, digest } = versionedTag;
|
// Find the versioned tag (v0.x.x) that has the same digest as "latest"
|
||||||
const updateAvailable = digest !== currentDigest;
|
const latestVersionTag = allResults.find(
|
||||||
|
(t) => t.digest === latestTag.digest && t.name.startsWith("v"),
|
||||||
|
);
|
||||||
|
|
||||||
return { latestVersion, updateAvailable };
|
if (!latestVersionTag) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = latestVersionTag.name;
|
||||||
|
|
||||||
|
// Use semver to compare versions for stable releases
|
||||||
|
const cleanedCurrent = semver.clean(currentVersion);
|
||||||
|
const cleanedLatest = semver.clean(latestVersion);
|
||||||
|
|
||||||
|
if (!cleanedCurrent || !cleanedLatest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the latest version is greater than the current version
|
||||||
|
const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching update data:", error);
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
const updateAvailable = searchedDigest !== currentDigest;
|
|
||||||
return { latestVersion: imageTag, updateAvailable };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TreeDataItem {
|
interface TreeDataItem {
|
||||||
@@ -254,11 +293,22 @@ fi`;
|
|||||||
export const reloadDockerResource = async (
|
export const reloadDockerResource = async (
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
serverId?: string,
|
serverId?: string,
|
||||||
|
version?: string,
|
||||||
) => {
|
) => {
|
||||||
const resourceType = await getDockerResourceType(resourceName, serverId);
|
const resourceType = await getDockerResourceType(resourceName, serverId);
|
||||||
let command = "";
|
let command = "";
|
||||||
if (resourceType === "service") {
|
if (resourceType === "service") {
|
||||||
command = `docker service update --force ${resourceName}`;
|
if (resourceName === "dokploy") {
|
||||||
|
const currentImageTag = getDokployImageTag();
|
||||||
|
let imageTag = version;
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
imageTag = currentImageTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`;
|
||||||
|
} else {
|
||||||
|
command = `docker service update --force ${resourceName}`;
|
||||||
|
}
|
||||||
} else if (resourceType === "standalone") {
|
} else if (resourceType === "standalone") {
|
||||||
command = `docker restart ${resourceName}`;
|
command = `docker restart ${resourceName}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
44
packages/server/src/services/web-server-settings.ts
Normal file
44
packages/server/src/services/web-server-settings.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { webServerSettings } from "@dokploy/server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the web server settings (singleton - only one row should exist)
|
||||||
|
*/
|
||||||
|
export const getWebServerSettings = async () => {
|
||||||
|
const settings = await db.query.webServerSettings.findFirst({
|
||||||
|
orderBy: (settings, { asc }) => [asc(settings.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
// Create default settings if none exist
|
||||||
|
const [newSettings] = await db
|
||||||
|
.insert(webServerSettings)
|
||||||
|
.values({})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return newSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update web server settings
|
||||||
|
*/
|
||||||
|
export const updateWebServerSettings = async (
|
||||||
|
updates: Partial<typeof webServerSettings.$inferInsert>,
|
||||||
|
) => {
|
||||||
|
const current = await getWebServerSettings();
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(webServerSettings)
|
||||||
|
.set({
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(webServerSettings.id, current?.id ?? ""))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user